updated features to upload pptx files

This commit is contained in:
DigiServer Developer
2025-11-15 01:26:12 +02:00
parent 9d4f932a95
commit 930a5bf636
24 changed files with 1963 additions and 2218 deletions

View File

@@ -11,24 +11,39 @@
<h2>📊 System Overview</h2>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Total Users:</span>
<span class="stat-value">{{ total_users or 0 }}</span>
<div class="stat-icon">👥</div>
<div class="stat-content">
<span class="stat-label">Total Users</span>
<span class="stat-value">{{ total_users or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Total Players:</span>
<span class="stat-value">{{ total_players or 0 }}</span>
<div class="stat-icon">🖥️</div>
<div class="stat-content">
<span class="stat-label">Total Players</span>
<span class="stat-value">{{ total_players or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Total Groups:</span>
<span class="stat-value">{{ total_groups or 0 }}</span>
<div class="stat-icon">📋</div>
<div class="stat-content">
<span class="stat-label">Total Playlists</span>
<span class="stat-value">{{ total_playlists or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Total Content:</span>
<span class="stat-value">{{ total_content or 0 }}</span>
<div class="stat-icon">📁</div>
<div class="stat-content">
<span class="stat-label">Media Files</span>
<span class="stat-value">{{ total_content or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Storage Used:</span>
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
<div class="stat-icon">💾</div>
<div class="stat-content">
<span class="stat-label">Storage Used</span>
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
</div>
</div>
</div>
</div>
@@ -44,13 +59,24 @@
</div>
</div>
<!-- Leftover Media Management Card -->
<div class="card management-card">
<h2>🗑️ Manage Leftover Media</h2>
<p>Clean up media files not assigned to any playlist</p>
<div class="card-actions">
<a href="{{ url_for('admin.leftover_media') }}" class="btn btn-warning">
Manage Leftover Files
</a>
</div>
</div>
<!-- Quick Actions Card -->
<div class="card">
<h2>⚡ Quick Actions</h2>
<div class="quick-actions">
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">View Players</a>
<a href="{{ url_for('groups.groups_list') }}" class="btn btn-secondary">View Groups</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">View Content</a>
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
</div>
</div>
</div>
@@ -71,22 +97,61 @@
.stat-item {
display: flex;
justify-content: space-between;
padding: 10px;
align-items: center;
gap: 12px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
border-radius: 8px;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
body.dark-mode .stat-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.stat-icon {
font-size: 2rem;
line-height: 1;
}
.stat-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.stat-label {
font-weight: 500;
font-size: 0.85rem;
color: #666;
}
body.dark-mode .stat-label {
color: #a0aec0;
}
.stat-value {
font-weight: bold;
font-size: 1.5rem;
color: #2c3e50;
}
body.dark-mode .stat-value {
color: #e2e8f0;
}
.management-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;

View File

@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %}
{% block content %}
<div style="max-width: 1400px; margin: 0 auto;">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
🗑️ Manage Leftover Media
</h1>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin
</a>
</div>
<!-- Overview Stats -->
<div class="card" style="margin-bottom: 30px;">
<h2>📊 Overview</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Media files that are not assigned to any playlist. These can be safely deleted to free up storage.
</p>
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Leftover Files</div>
<div style="font-size: 24px; font-weight: bold;">{{ total_leftover }}</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Size</div>
<div style="font-size: 24px; font-weight: bold;">{{ "%.2f"|format(total_leftover_size_mb) }} MB</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Images</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Videos</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)</div>
</div>
</div>
</div>
<!-- Images Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>📷 Leftover Images ({{ leftover_images|length }})</h2>
{% if leftover_images %}
<form method="POST" action="{{ url_for('admin.delete_leftover_images') }}"
onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_images|length }} leftover images? This cannot be undone!');"
style="display: inline;">
<button type="submit" class="btn btn-danger">
🗑️ Delete All Images
</button>
</form>
{% endif %}
</div>
{% if leftover_images %}
<div style="max-height: 400px; overflow-y: auto;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for img in leftover_images %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📷 {{ img.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ img.duration }}s</td>
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover images found. All images are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- Videos Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="margin: 0;">🎥 Leftover Videos ({{ leftover_videos|length }})</h2>
{% if leftover_videos %}
<form method="POST" action="{{ url_for('admin.delete_leftover_videos') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_videos|length }} leftover videos? This action cannot be undone!');">
<button type="submit" class="btn btn-danger" style="padding: 8px 16px; font-size: 14px;">
🗑️ Delete All Videos
</button>
</form>
{% endif %}
</div>
{% if leftover_videos %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for video in leftover_videos %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ video.duration }}s</td>
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover videos found. All videos are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- PDFs Section -->
<div class="card" style="margin-bottom: 30px;">
<h2>📄 Leftover PDFs ({{ leftover_pdfs|length }})</h2>
{% if leftover_pdfs %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for pdf in leftover_pdfs %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ pdf.duration }}s</td>
<td style="padding: 10px;">{{ pdf.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover PDFs found. All PDFs are assigned to playlists!</p>
</div>
{% endif %}
</div>
</div>
<style>
body.dark-mode .card {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2 {
color: #e2e8f0;
}
body.dark-mode .stat-item {
background: #1a202c !important;
color: #e2e8f0;
}
body.dark-mode .table thead {
background: #1a202c !important;
}
body.dark-mode .table th,
body.dark-mode .table td {
color: #e2e8f0;
border-color: #4a5568 !important;
}
body.dark-mode .table tr {
border-color: #4a5568 !important;
}
body.dark-mode .table tr:hover {
background: #1a202c;
}
</style>
{% endblock %}

View File

@@ -444,10 +444,19 @@
{% endif %}
</td>
<td style="padding: 12px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm">
👁️ View
</a>
<div style="display: flex; gap: 0.5rem;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm">
⚙️ Manage
</a>
{% if player.playlist_id %}
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" target="_blank"
title="View live content preview">
🖥️ Live
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}

View File

@@ -5,14 +5,15 @@
{% block content %}
<style>
.upload-container {
max-width: 900px;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.upload-zone {
border: 3px dashed #ced4da;
border-radius: 12px;
padding: 60px 40px;
border: 2px dashed #ced4da;
border-radius: 8px;
padding: 25px 20px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
@@ -54,18 +55,21 @@
}
.file-list {
margin-top: 20px;
margin-top: 15px;
max-height: 200px;
overflow-y: auto;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
padding: 8px 10px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 10px;
border-radius: 4px;
margin-bottom: 6px;
font-size: 13px;
}
body.dark-mode .file-item {
@@ -80,7 +84,7 @@
}
.file-icon {
font-size: 24px;
font-size: 20px;
}
.remove-file {
@@ -97,7 +101,7 @@
}
.form-group {
margin-bottom: 20px;
margin-bottom: 15px;
}
.form-group label {
@@ -205,106 +209,100 @@
</style>
<div class="upload-container">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 32px; height: 32px;">
Upload Media Files
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
Upload Media
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
<!-- Playlist Selector -->
<div class="card" style="margin-bottom: 30px;">
<h2 style="margin-bottom: 15px;">📋 Select Target Playlist (Optional)</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Choose a playlist to directly add uploaded files, or leave blank to add to media library only.
</p>
<!-- Compact Two-Column Layout -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="form-group">
<label for="playlist_id">Target Playlist</label>
<select name="playlist_id" id="playlist_id" class="form-control">
<option value="">-- Media Library Only (Don't add to playlist) --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) - v{{ playlist.version }} - {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;">
💡 Tip: You can add files to playlists later from the media library
</small>
</div>
</div>
<!-- Upload Zone -->
<div class="card" style="margin-bottom: 30px;">
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
Select Files
</h2>
<div class="upload-zone" id="upload-zone">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3; margin-bottom: 20px;">
<h3 style="margin-bottom: 10px;">Drag and Drop Files Here</h3>
<p style="color: #6c757d; margin: 15px 0;">or</p>
<div class="file-input-wrapper">
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Browse Files
</label>
<input type="file" id="file-input" name="files" multiple
accept="image/*,video/*,.pdf,.ppt,.pptx">
<!-- Left Column: Upload Zone -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
Select Files
</h2>
<div class="upload-zone" id="upload-zone">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
<h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
<p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
<div class="file-input-wrapper">
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Browse
</label>
<input type="file" id="file-input" name="files" multiple
accept="image/*,video/*,.pdf,.ppt,.pptx">
</div>
<p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
<strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
</p>
</div>
<p style="font-size: 14px; color: #999; margin-top: 20px;">
<strong>Supported formats:</strong><br>
Images: JPG, PNG, GIF, BMP<br>
Videos: MP4, AVI, MOV, MKV, WEBM<br>
Documents: PDF, PPT, PPTX
</p>
<div id="file-list" class="file-list"></div>
</div>
<div id="file-list" class="file-list"></div>
</div>
<!-- Upload Settings -->
<div class="card" style="margin-bottom: 30px;">
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Upload Settings
</h2>
<div class="form-group">
<label for="content_type">Media Type</label>
<select name="content_type" id="content_type" class="form-control">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF Document</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;">
This will be auto-detected from file extension
</small>
</div>
<div class="form-group">
<label for="duration">Default Duration (seconds)</label>
<input type="number" name="duration" id="duration" class="form-control"
value="10" min="1" max="300">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long each item should display (for images and PDFs)
</small>
<!-- Right Column: Settings -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
Upload Settings
</h2>
<div class="form-group">
<label for="playlist_id">Target Playlist (Optional)</label>
<select name="playlist_id" id="playlist_id" class="form-control">
<option value="">-- Media Library Only --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
💡 Add to playlists later if needed
</small>
</div>
<div class="form-group">
<label for="content_type">Media Type</label>
<select name="content_type" id="content_type" class="form-control">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="pptx">PPTX</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Auto-detected from file extension
</small>
</div>
<div class="form-group">
<label for="duration">Default Duration (seconds)</label>
<input type="number" name="duration" id="duration" class="form-control"
value="10" min="1" max="300">
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Display time for images and PDFs
</small>
</div>
<!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Files
</button>
</div>
</div>
<!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px; filter: brightness(0) invert(1);">
Upload Files
</button>
</form>
</div>
@@ -350,18 +348,97 @@
// Handle file input
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
console.log('File input changed, files:', e.target.files.length);
if (e.target.files.length > 0) {
handleFiles(e.target.files);
}
});
// Click upload zone to trigger file input
uploadZone.addEventListener('click', () => {
// Click upload zone to trigger file input (but not if clicking on the label)
uploadZone.addEventListener('click', (e) => {
// Don't trigger if clicking on the label or button
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
return;
}
fileInput.click();
});
function handleFiles(files) {
console.log('handleFiles called with', files.length, 'file(s)');
selectedFiles = Array.from(files);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
// Auto-detect media type and duration from first file
if (selectedFiles.length > 0) {
console.log('Auto-detecting for first file:', selectedFiles[0].name);
autoDetectMediaType(selectedFiles[0]);
autoDetectDuration(selectedFiles[0]);
}
}
function autoDetectMediaType(file) {
const ext = file.name.split('.').pop().toLowerCase();
const contentTypeSelect = document.getElementById('content_type');
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
contentTypeSelect.value = 'image';
console.log('Set type to: image');
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
contentTypeSelect.value = 'video';
console.log('Set type to: video');
} else if (ext === 'pdf') {
contentTypeSelect.value = 'pdf';
console.log('Set type to: pdf');
} else if (['ppt', 'pptx'].includes(ext)) {
contentTypeSelect.value = 'pptx';
console.log('Set type to: pptx');
}
}
function autoDetectDuration(file) {
const ext = file.name.split('.').pop().toLowerCase();
const durationInput = document.getElementById('duration');
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
// For videos, try to get actual duration
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
console.log('Processing as video...');
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.ceil(video.duration);
console.log('Video duration detected:', duration, 'seconds');
if (duration && duration > 0) {
durationInput.value = duration;
}
};
video.onerror = function() {
console.log('Video loading error, using default 30s');
window.URL.revokeObjectURL(video.src);
durationInput.value = 30; // Default for videos if can't read duration
};
video.src = URL.createObjectURL(file);
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
// Images: default 10 seconds
console.log('Setting image duration: 10s');
durationInput.value = 10;
} else if (ext === 'pdf') {
// PDFs: default 15 seconds per page (estimate)
console.log('Setting PDF duration: 15s');
durationInput.value = 15;
} else if (['ppt', 'pptx'].includes(ext)) {
// Presentations: default 20 seconds per slide (estimate)
console.log('Setting presentation duration: 20s');
durationInput.value = 20;
}
}
function displayFiles() {

View File

@@ -3,6 +3,215 @@
{% block title %}Manage Player - {{ player.name }}{% endblock %}
{% block content %}
<style>
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
outline: none;
}
.info-box {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.info-box.neutral {
background: #f8f9fa;
}
body.dark-mode .info-box.neutral {
background: #1a202c;
border: 1px solid #4a5568;
}
.info-box.success {
background: #d4edda;
color: #155724;
}
body.dark-mode .info-box.success {
background: #1a4d2e;
color: #86efac;
border: 1px solid #48bb78;
}
.info-box.warning {
background: #fff3cd;
color: #856404;
}
body.dark-mode .info-box.warning {
background: #4a3800;
color: #fbbf24;
border: 1px solid #ecc94b;
}
.log-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
background: #f8f9fa;
border-left: 4px solid;
}
body.dark-mode .log-item {
background: #2d3748;
}
.log-item pre {
background: #fff;
border: 1px solid #ddd;
}
body.dark-mode .log-item pre {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2,
body.dark-mode h3,
body.dark-mode h4 {
color: #e2e8f0;
}
body.dark-mode p {
color: #a0aec0;
}
body.dark-mode strong {
color: #e2e8f0;
}
body.dark-mode code {
background: #1a202c;
color: #e2e8f0;
padding: 2px 6px;
border-radius: 3px;
}
body.dark-mode small {
color: #718096;
}
.credential-item {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e2e8f0;
}
body.dark-mode .credential-item {
border-bottom-color: #4a5568;
}
.credential-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.credential-label {
font-weight: 600;
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: #495057;
}
body.dark-mode .credential-label {
color: #a0aec0;
}
.credential-value {
font-family: 'Courier New', monospace;
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
word-break: break-all;
font-size: 0.85rem;
border: 1px solid #dee2e6;
}
body.dark-mode .credential-value {
background: #0d1117;
border-color: #4a5568;
color: #e2e8f0;
}
.status-card {
margin-bottom: 2rem;
}
.status-card.online {
background: #d4edda;
}
body.dark-mode .status-card.online {
background: #1a4d2e;
border: 1px solid #48bb78;
}
.status-card.offline {
background: #f8d7da;
}
body.dark-mode .status-card.offline {
background: #4a1a1a;
border: 1px solid #dc3545;
}
.status-card.other {
background: #fff3cd;
}
body.dark-mode .status-card.other {
background: #4a3800;
border: 1px solid #ecc94b;
}
.playlist-stats {
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
body.dark-mode .playlist-stats {
background: #1a202c;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-stats > div > div:first-child {
color: #a0aec0;
}
</style>
<div style="margin-bottom: 2rem;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
@@ -15,7 +224,7 @@
</div>
<!-- Player Status Overview -->
<div class="card" style="margin-bottom: 2rem; background: {% if player.status == 'online' %}#d4edda{% elif player.status == 'offline' %}#f8d7da{% else %}#fff3cd{% endif %};">
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
Status:
{% if player.status == 'online' %}
@@ -52,6 +261,47 @@
</p>
</div>
<!-- Playlist Overview Card -->
{% if current_playlist %}
<div class="card" style="margin-bottom: 2rem;">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
Current Playlist: {{ current_playlist.name }}
</h2>
<div class="playlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Total Items</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.contents.count() }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Playlist Version</div>
<div style="font-size: 1.5rem; font-weight: bold;">v{{ current_playlist.version }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.orientation }}</div>
</div>
</div>
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Edit Playlist Content
</a>
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;"
target="_blank">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
View Live Content
</a>
</div>
</div>
{% endif %}
<!-- Three Column Layout -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
@@ -64,33 +314,44 @@
<form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="update_credentials">
<div style="margin-bottom: 1rem;">
<label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Player Name *</label>
<div class="form-group">
<label for="name">Player Name *</label>
<input type="text" id="name" name="name" value="{{ player.name }}"
required minlength="3"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
required minlength="3" class="form-control">
</div>
<div style="margin-bottom: 1rem;">
<label for="location" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Location</label>
<div class="form-group">
<label for="location">Location</label>
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
placeholder="e.g., Main Lobby"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
placeholder="e.g., Main Lobby" class="form-control">
</div>
<div style="margin-bottom: 1rem;">
<label for="orientation" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Orientation</label>
<select id="orientation" name="orientation"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<div class="form-group">
<label for="orientation">Orientation</label>
<select id="orientation" name="orientation" class="form-control">
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
</select>
</div>
<div style="padding: 1rem; background: #f8f9fa; border-radius: 4px; margin-bottom: 1rem;">
<p style="margin: 0; font-size: 0.9rem;"><strong>Hostname:</strong> {{ player.hostname }}</p>
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Auth Code:</strong> <code>{{ player.auth_code }}</code></p>
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Quick Connect:</strong> {{ player.quickconnect_code or 'Not set' }}</p>
<div class="info-box neutral">
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem; color: #495057;">🔑 Player Credentials</h4>
<div class="credential-item">
<span class="credential-label">Hostname</span>
<div class="credential-value">{{ player.hostname }}</div>
</div>
<div class="credential-item">
<span class="credential-label">Auth Code</span>
<div class="credential-value">{{ player.auth_code }}</div>
</div>
<div class="credential-item">
<span class="credential-label">Quick Connect Code (Hashed)</span>
<div class="credential-value" style="font-size: 0.75rem;">{{ player.quickconnect_code or 'Not set' }}</div>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">⚠️ This is the hashed version for security</small>
</div>
</div>
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
@@ -109,10 +370,9 @@
<form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="assign_playlist">
<div style="margin-bottom: 1rem;">
<label for="playlist_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Select Playlist</label>
<select id="playlist_id" name="playlist_id"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<div class="form-group">
<label for="playlist_id">Select Playlist</label>
<select id="playlist_id" name="playlist_id" class="form-control">
<option value="">-- No Playlist (Unassign) --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}"
@@ -124,8 +384,8 @@
</div>
{% if current_playlist %}
<div style="padding: 1rem; background: #d4edda; border-radius: 4px; margin-bottom: 1rem;">
<h4 style="margin: 0 0 0.5rem 0; color: #155724;">Currently Assigned:</h4>
<div class="info-box success">
<h4 style="margin: 0 0 0.5rem 0;">Currently Assigned:</h4>
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
@@ -134,8 +394,8 @@
</p>
</div>
{% else %}
<div style="padding: 1rem; background: #fff3cd; border-radius: 4px; margin-bottom: 1rem;">
<p style="margin: 0; color: #856404; display: flex; align-items: center; gap: 0.5rem;">
<div class="info-box warning">
<p style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
No playlist currently assigned to this player.
</p>

View File

@@ -1,10 +1,234 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ player.name }} - Live Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
font-family: Arial, sans-serif;
}
#player-container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
#media-display {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
display: none;
}
#media-display.active {
display: block;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
text-align: center;
}
.info-overlay {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
}
.controls {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1000;
transition: opacity 0.3s ease;
}
.controls button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.controls button:hover {
background: rgba(255, 255, 255, 0.3);
}
.no-content {
color: white;
text-align: center;
font-size: 24px;
}
</style>
</head>
<body>
<div id="player-container">
<div class="loading" id="loading">Loading playlist...</div>
<img id="media-display" alt="Content">
<video id="video-display" style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
<div class="no-content" id="no-content" style="display: none;">
<p>💭 No content in playlist</p>
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
</div>
</div>
<div class="info-overlay" id="info-overlay" style="display: none;">
<div id="current-item">Item: -</div>
<div id="playlist-info">Playlist: {{ playlist|length }} items</div>
</div>
<div class="controls">
<button onclick="toggleFullscreen()">🔳 Fullscreen</button>
<button onclick="restartPlaylist()">🔄 Restart</button>
<button onclick="window.close()">✖️ Close</button>
</div>
{% block title %}Player Fullscreen{% endblock %}
{% block content %}
<div class="container">
<h2>Player Fullscreen View</h2>
<p>Fullscreen player view - placeholder</p>
</div>
{% endblock %}
<script>
const playlist = {{ playlist|tojson }};
let currentIndex = 0;
let timer = null;
let inactivityTimer = null;
const imgDisplay = document.getElementById('media-display');
const videoDisplay = document.getElementById('video-display');
const loading = document.getElementById('loading');
const noContent = document.getElementById('no-content');
const infoOverlay = document.getElementById('info-overlay');
const currentItemDiv = document.getElementById('current-item');
const controls = document.querySelector('.controls');
function playNext() {
if (!playlist || playlist.length === 0) {
loading.style.display = 'none';
noContent.style.display = 'block';
return;
}
const item = playlist[currentIndex];
loading.style.display = 'none';
infoOverlay.style.display = 'block';
// Update info
currentItemDiv.textContent = `Item ${currentIndex + 1}/${playlist.length}: ${item.filename}`;
// Hide both displays
imgDisplay.style.display = 'none';
videoDisplay.style.display = 'none';
videoDisplay.pause();
if (item.type === 'video') {
videoDisplay.src = item.url;
videoDisplay.style.display = 'block';
videoDisplay.play();
// When video ends, move to next
videoDisplay.onended = () => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
};
} else {
// Image or PDF
imgDisplay.src = item.url;
imgDisplay.style.display = 'block';
imgDisplay.classList.add('active');
// Clear any existing timer
if (timer) clearTimeout(timer);
// Show for specified duration
timer = setTimeout(() => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
}, (item.duration || 10) * 1000);
}
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function restartPlaylist() {
currentIndex = 0;
if (timer) clearTimeout(timer);
videoDisplay.pause();
playNext();
}
// Auto-hide controls after 5 seconds of inactivity
function resetInactivityTimer() {
// Show controls
controls.style.opacity = '1';
controls.style.pointerEvents = 'auto';
// Clear existing timer
if (inactivityTimer) {
clearTimeout(inactivityTimer);
}
// Set new timer to hide controls after 5 seconds
inactivityTimer = setTimeout(() => {
controls.style.opacity = '0';
controls.style.pointerEvents = 'none';
}, 5000);
}
// Track user activity to show/hide controls
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('mousedown', resetInactivityTimer);
document.addEventListener('keydown', resetInactivityTimer);
document.addEventListener('touchstart', resetInactivityTimer);
// Start playing when page loads
window.addEventListener('load', () => {
playNext();
resetInactivityTimer(); // Start inactivity timer
});
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'f' || e.key === 'F') {
toggleFullscreen();
} else if (e.key === 'r' || e.key === 'R') {
restartPlaylist();
} else if (e.key === 'Escape' && document.fullscreenElement) {
document.exitFullscreen();
}
});
</script>
</body>
</html>

View File

@@ -3,6 +3,129 @@
{% block title %}Players - DigiServer v2{% endblock %}
{% block content %}
<style>
body.dark-mode h1 {
color: #e2e8f0;
}
.players-table {
width: 100%;
border-collapse: collapse;
}
.players-table thead tr {
background: #f8f9fa;
text-align: left;
}
body.dark-mode .players-table thead tr {
background: #1a202c;
}
.players-table th {
padding: 12px;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
color: #495057;
}
body.dark-mode .players-table th {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
.players-table tbody tr {
border-bottom: 1px solid #dee2e6;
}
body.dark-mode .players-table tbody tr {
border-bottom-color: #4a5568;
}
.players-table tbody tr:hover {
background: #f8f9fa;
}
body.dark-mode .players-table tbody tr:hover {
background: #2d3748;
}
.players-table td {
padding: 12px;
color: #2d3748;
}
body.dark-mode .players-table td {
color: #e2e8f0;
}
.players-table td strong {
color: #2d3748;
}
body.dark-mode .players-table td strong {
color: #e2e8f0;
}
.players-table code {
background: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
body.dark-mode .players-table code {
background: #1a202c;
color: #e2e8f0;
}
.status-badge {
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
color: white;
}
.status-badge.online {
background: #28a745;
}
.status-badge.offline {
background: #6c757d;
}
.text-muted {
color: #6c757d;
}
body.dark-mode .text-muted {
color: #718096;
}
.info-box {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
padding: 15px;
border-radius: 5px;
}
body.dark-mode .info-box {
background: #1a365d;
border-color: #2c5282;
color: #90cdf4;
}
.info-box a {
color: #0c5460;
text-decoration: underline;
}
body.dark-mode .info-box a {
color: #90cdf4;
}
</style>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Players</h1>
@@ -11,49 +134,49 @@
{% if players %}
<div class="card">
<table style="width: 100%; border-collapse: collapse;">
<table class="players-table">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Last Seen</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Location</th>
<th>Orientation</th>
<th>Status</th>
<th>Last Seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<tr>
<td>
<strong>{{ player.name }}</strong>
</td>
<td style="padding: 12px;">
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{{ player.hostname }}</code>
<td>
<code>{{ player.hostname }}</code>
</td>
<td style="padding: 12px;">
<td>
{{ player.location or '-' }}
</td>
<td style="padding: 12px;">
<td>
{{ player.orientation or 'Landscape' }}
</td>
<td style="padding: 12px;">
<td>
{% set status = player_statuses.get(player.id, {}) %}
{% if status.get('is_online') %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Online</span>
<span class="status-badge online">Online</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Offline</span>
<span class="status-badge offline">Offline</span>
{% endif %}
</td>
<td style="padding: 12px;">
<td>
{% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span style="color: #6c757d;">Never</span>
<span class="text-muted">Never</span>
{% endif %}
</td>
<td style="padding: 12px;">
<td>
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn btn-info btn-sm" title="Manage Player">
⚙️ Manage
@@ -65,8 +188,8 @@
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
No players yet. <a href="{{ url_for('players.add_player') }}" style="color: #0c5460; text-decoration: underline;">Add your first player</a>
<div class="info-box">
No players yet. <a href="{{ url_for('players.add_player') }}">Add your first player</a>
</div>
{% endif %}
</div>

View File

@@ -219,6 +219,92 @@
.duration-input {
width: 80px;
text-align: center;
transition: all 0.3s ease;
}
.duration-input:hover {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.duration-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.save-duration-btn {
transition: all 0.2s ease;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Dark mode support */
body.dark-mode .playlist-section {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
body.dark-mode .section-header {
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table thead {
background: #1a202c;
}
body.dark-mode .playlist-table th {
color: #cbd5e0;
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table td {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .playlist-table tr:hover {
background: #1a202c;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #667eea;
background: #2d3748;
}
body.dark-mode .add-content-form {
background: #1a202c;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
body.dark-mode .empty-state {
color: #718096;
}
body.dark-mode .drag-handle {
color: #718096;
}
</style>
@@ -290,9 +376,9 @@
</thead>
<tbody id="playlist-tbody">
{% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
<tr class="draggable-row" data-content-id="{{ content.id }}">
<td>
<span class="drag-handle">⋮⋮</span>
<span class="drag-handle" draggable="true">⋮⋮</span>
</td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
@@ -304,11 +390,28 @@
{% endif %}
</td>
<td>
<input type="number"
class="form-control duration-input"
value="{{ content.duration }}"
min="1"
onchange="updateDuration({{ content.id }}, this.value)">
<div style="display: flex; align-items: center; gap: 5px;">
<input type="number"
class="form-control duration-input"
id="duration-{{ content.id }}"
value="{{ content._playlist_duration }}"
min="1"
draggable="false"
onclick="event.stopPropagation()"
onmousedown="event.stopPropagation()"
oninput="markDurationChanged({{ content.id }})"
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})"
style="width: 60px; padding: 5px 8px;">
<button type="button"
class="btn btn-success btn-sm save-duration-btn"
id="save-btn-{{ content.id }}"
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
onmousedown="event.stopPropagation()"
style="display: none; padding: 5px 10px; font-size: 12px; cursor: pointer;"
title="Save duration (or press Enter)">
💾
</button>
</div>
</td>
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
<td>
@@ -335,7 +438,7 @@
</div>
<!-- Add Content Section -->
{% if available_files %}
{% if available_content %}
<div class="playlist-section">
<div class="section-header">
<h2> Add Existing Content</h2>
@@ -344,11 +447,11 @@
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
class="add-content-form">
<div class="form-group">
<label for="filename">Select File:</label>
<select name="filename" id="filename" class="form-control" required>
<option value="" disabled selected>Choose a file...</option>
{% for filename in available_files %}
<option value="{{ filename }}">{{ filename }}</option>
<label for="content_id">Select Content:</label>
<select name="content_id" id="content_id" class="form-control" required>
<option value="" disabled selected>Choose content...</option>
{% for content in available_content %}
<option value="{{ content.id }}">{{ content.filename }} ({{ content.content_type }})</option>
{% endfor %}
</select>
</div>
@@ -380,20 +483,41 @@ document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('playlist-tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('.draggable-row');
// Set up drag handles
const dragHandles = tbody.querySelectorAll('.drag-handle');
dragHandles.forEach(handle => {
handle.addEventListener('dragstart', handleDragStart);
});
// Set up drop zones on rows
const rows = tbody.querySelectorAll('.draggable-row');
rows.forEach(row => {
row.addEventListener('dragstart', handleDragStart);
row.addEventListener('dragover', handleDragOver);
row.addEventListener('drop', handleDrop);
row.addEventListener('dragend', handleDragEnd);
});
// Prevent dragging from inputs and buttons
const inputs = document.querySelectorAll('.duration-input, button');
inputs.forEach(input => {
input.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
input.addEventListener('click', (e) => {
e.stopPropagation();
});
});
});
function handleDragStart(e) {
draggedElement = this;
this.classList.add('dragging');
// Get the parent row
const row = e.target.closest('.draggable-row');
if (!row) return;
draggedElement = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', row.innerHTML);
}
function handleDragOver(e) {
@@ -464,7 +588,41 @@ function saveOrder() {
});
}
function updateDuration(contentId, duration) {
function markDurationChanged(contentId) {
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const input = document.getElementById(`duration-${contentId}`);
// Show save button if value changed
if (input.value !== input.defaultValue) {
saveBtn.style.display = 'inline-block';
input.style.borderColor = '#ffc107';
} else {
saveBtn.style.display = 'none';
input.style.borderColor = '';
}
}
function saveDuration(contentId) {
const inputElement = document.getElementById(`duration-${contentId}`);
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const duration = parseInt(inputElement.value);
// Validate duration
if (duration < 1) {
alert('Duration must be at least 1 second');
inputElement.value = inputElement.defaultValue;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
return;
}
const originalValue = inputElement.defaultValue;
// Visual feedback
inputElement.disabled = true;
saveBtn.disabled = true;
saveBtn.textContent = '⏳';
const formData = new FormData();
formData.append('duration', duration);
@@ -476,15 +634,65 @@ function updateDuration(contentId, duration) {
.then(data => {
if (data.success) {
console.log('Duration updated successfully');
// Update total duration
location.reload();
inputElement.style.borderColor = '#28a745';
inputElement.defaultValue = duration;
saveBtn.textContent = '✓';
// Update total duration display
updateTotalDuration();
setTimeout(() => {
inputElement.style.borderColor = '';
inputElement.disabled = false;
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
} else {
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration: ' + data.message);
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
}
})
.catch(error => {
console.error('Error:', error);
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration');
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
});
}
function updateTotalDuration() {
const durationInputs = document.querySelectorAll('.duration-input');
let total = 0;
durationInputs.forEach(input => {
total += parseInt(input.value) || 0;
});
const statValues = document.querySelectorAll('.stat-value');
statValues.forEach((element, index) => {
const label = element.parentElement.querySelector('.stat-label');
if (label && label.textContent.includes('Total Duration')) {
element.textContent = total + 's';
}
});
}
</script>