Add real-time upload progress tracking, mobile-optimized manage_group page with player status cards

Features:
- Real-time upload progress tracking with AJAX polling and session-based monitoring
- API endpoint /api/upload_progress/<session_id> for progress updates
- Video conversion progress tracking with background threads
- Mobile-responsive design for manage_group page
- Player status cards with feedback, playlist sync, and last activity
- Bootstrap Icons integration throughout UI
- Responsive layout (1/4 group info, 3/4 players on desktop)
- Video thumbnails with play icon, image thumbnails in media lists
- Bulk selection and delete for group media
- Enhanced logging for video conversion debugging
This commit is contained in:
DigiServer Developer
2025-11-03 16:09:18 +02:00
parent 52344a27a6
commit d0fbfe25b3
5 changed files with 720 additions and 160 deletions

View File

@@ -3,8 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Group</title>
<title>Manage Group - {{ group.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
@@ -17,16 +18,72 @@
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
/* Logo styling */
.logo {
max-height: 80px;
margin-right: 15px;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.logo {
max-height: 50px;
margin-right: 10px;
}
h1 {
font-size: 1.5rem;
font-size: 1.3rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.9rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
}
.btn-sm {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.card {
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
.card-body {
padding: 0.75rem;
}
.badge {
font-size: 0.75rem;
}
/* Stack buttons vertically on mobile */
.action-buttons .btn {
display: block;
width: 100%;
margin-bottom: 0.5rem;
}
/* Smaller text on mobile */
small {
font-size: 0.75rem;
}
/* Reduce padding in tables */
.list-group-item {
padding: 0.5rem;
}
}
/* Smaller screens - further optimization */
@media (max-width: 576px) {
.container-fluid {
padding-left: 10px;
padding-right: 10px;
}
h1 {
font-size: 1.1rem;
}
.card-header h5 {
font-size: 0.95rem;
}
}
@@ -49,38 +106,135 @@
.drag-over {
border-top: 2px solid #0d6efd;
}
/* Player status card compact design */
.player-status-card {
font-size: 0.9rem;
}
@media (max-width: 768px) {
.player-status-card {
font-size: 0.85rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
<!-- Group Information Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>Group Info</h2>
</div>
<div class="card-body">
<p><strong>Group Name:</strong> {{ group.name }}</p>
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
<div class="container-fluid py-3 py-md-4 py-lg-5">
<!-- Header with Logo and Title -->
<div class="d-flex justify-content-start align-items-center mb-3 mb-md-4">
{% if logo_exists %}
<img src="{{ url_for('static', filename='resurse/logo.png') }}" alt="Logo" class="logo">
{% endif %}
<div>
<h1 class="mb-1">Manage Group</h1>
<p class="text-muted mb-0 d-none d-md-block">{{ group.name }}</p>
</div>
</div>
<!-- List of Players in the Group -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-secondary text-white">
<h2>Players in Group</h2>
<!-- Mobile: Show group name if not shown in header -->
<div class="d-md-none mb-3">
<div class="badge bg-primary fs-6">{{ group.name }}</div>
</div>
<!-- Row with Group Info (left) and Players Status (right) -->
<div class="row mb-3 mb-md-4">
<!-- Group Information Card - Responsive width -->
<div class="col-lg-3 col-md-4 col-12 mb-3">
<div class="card h-100 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Group Info</h5>
</div>
<div class="card-body p-3">
<div class="mb-2">
<small class="text-muted">Group Name</small>
<p class="mb-0"><strong>{{ group.name }}</strong></p>
</div>
<div class="mb-2">
<small class="text-muted">Players</small>
<p class="mb-0"><strong>{{ group.players|length }}</strong></p>
</div>
<div class="mb-0">
<small class="text-muted">Playlist Version</small>
<p class="mb-0"><span class="badge bg-info mt-1">v{{ group.playlist_version }}</span></p>
</div>
</div>
</div>
</div>
<div class="card-body">
<ul class="list-group">
{% for player in group.players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ player.username }}</strong> ({{ player.hostname }})
<!-- Players Status Cards Container - 3/4 width on large screens -->
<div class="col-lg-9 col-md-8 col-12">
<div class="card {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-display me-2"></i>Players ({{ group.players|length }})</h5>
</div>
<div class="card-body p-2 p-md-3">
{% if players_status %}
<div class="row g-2 g-md-3">
{% for player_status in players_status %}
<div class="col-xl-4 col-lg-6 col-12 mb-2">
<div class="card h-100 border-primary player-status-card {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0"><i class="bi bi-tv me-1"></i>{{ player_status.player.username }}</h6>
<a href="{{ url_for('player_page', player_id=player_status.player.id) }}"
class="btn btn-sm btn-light py-0 px-2" title="View Details">
<i class="bi bi-eye"></i>
</a>
</div>
<div class="card-body p-2">
<div class="mb-2">
<small class="text-muted"><i class="bi bi-hdd-network me-1"></i>Hostname:</small>
<small class="d-block">{{ player_status.player.hostname }}</small>
</div>
{% if player_status.feedback %}
<div class="mb-2">
<small class="text-muted"><i class="bi bi-activity me-1"></i>Status:</small>
<span class="badge bg-{{ 'success' if player_status.feedback[0].status in ['active', 'playing'] else 'danger' }}">
{{ player_status.feedback[0].status|title }}
</span>
</div>
<div class="mb-2">
<small class="text-muted"><i class="bi bi-clock me-1"></i>Last Activity:</small>
<small class="d-block">{{ player_status.feedback[0].timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
<div class="mb-2">
<small class="text-muted"><i class="bi bi-chat-dots me-1"></i>Message:</small>
<small class="d-block text-muted">{{ player_status.feedback[0].message[:50] }}{% if player_status.feedback[0].message|length > 50 %}...{% endif %}</small>
</div>
<div class="mb-0">
<small class="text-muted"><i class="bi bi-list-check me-1"></i>Playlist:</small>
{% if player_status.feedback[0].playlist_version %}
{% if player_status.feedback[0].playlist_version|int == player_status.server_playlist_version %}
<span class="badge bg-success">v{{ player_status.feedback[0].playlist_version }} ✓</span>
<small class="text-success d-block">In sync</small>
{% else %}
<span class="badge bg-warning text-dark">v{{ player_status.feedback[0].playlist_version }}</span>
<small class="text-warning d-block">⚠ Out of sync (server: v{{ player_status.server_playlist_version }})</small>
{% endif %}
{% else %}
<span class="badge bg-secondary">Unknown</span>
{% endif %}
</div>
{% else %}
<div class="text-center text-muted py-2">
<p class="mb-1"><small>No status data</small></p>
<small>Player hasn't reported yet</small>
</div>
{% endif %}
</div>
</div>
</div>
</li>
{% endfor %}
</ul>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-3">
<i class="bi bi-inbox display-4 d-block mb-2"></i>
<p>No players in this group</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
@@ -130,16 +284,23 @@
<!-- Media Thumbnail and Name -->
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
alt="thumbnail"
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
onerror="this.style.display='none';">
{% set file_ext = media.file_name.lower().split('.')[-1] %}
{% if file_ext in ['mp4', 'avi', 'mkv', 'mov', 'webm'] %}
<!-- Video file - show generic video icon -->
<div style="width: 48px; height: 48px; margin-right: 10px; border-radius: 4px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5v14l11-7z" fill="white"/>
</svg>
</div>
{% else %}
<!-- Image file - show actual thumbnail -->
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
alt="thumbnail"
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
onerror="this.style.display='none';">
{% endif %}
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>
<<<<<<< HEAD
=======
>>>>>>> 2255cc2 (Show media thumbnails in manage group page, matching player page style)
<form action="{{ url_for('edit_group_media_route', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
<div class="input-group me-2">
<span class="input-group-text">seconds</span>
@@ -162,12 +323,19 @@
</div>
<!-- Upload Media Button -->
<div class="text-center mb-4">
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
<div class="text-center mb-3 action-buttons">
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}"
class="btn btn-primary btn-lg">
<i class="bi bi-cloud-upload me-2"></i>Upload Media
</a>
</div>
<!-- Back to Dashboard Button -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
<div class="text-center mb-3">
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left me-2"></i>Back to Dashboard
</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>

View File

@@ -214,10 +214,22 @@
<!-- Media Thumbnail and Name -->
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
alt="thumbnail"
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
onerror="this.style.display='none';">
{% set file_ext = media.file_name.lower().split('.')[-1] %}
{% if file_ext in ['mp4', 'avi', 'mkv', 'mov', 'webm'] %}
<!-- Video Icon for video files -->
<div style="width: 48px; height: 48px; margin-right: 10px; border-radius: 4px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="white" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M6.271 5.055a.5.5 0 0 1 .52.038l3.5 2.5a.5.5 0 0 1 0 .814l-3.5 2.5A.5.5 0 0 1 6 10.5v-5a.5.5 0 0 1 .271-.445z"/>
</svg>
</div>
{% else %}
<!-- Image thumbnail for image files -->
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
alt="thumbnail"
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
onerror="this.style.display='none';">
{% endif %}
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>

View File

@@ -57,7 +57,7 @@
{% endif %}
<h1 class="mb-0">Upload Content</h1>
</div>
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
<input type="hidden" name="return_url" value="{{ return_url }}">
<div class="row">
<div class="col-md-6 col-12">
@@ -223,82 +223,127 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
let progressInterval = null;
let sessionId = null;
let statusModal = null;
let returnUrl = '{{ return_url }}';
// Generate unique session ID for this upload
function generateSessionId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function handleFormSubmit(event) {
event.preventDefault(); // Prevent default form submission
// Generate session ID and add it to the form
sessionId = generateSessionId();
const form = document.getElementById('upload-form');
let sessionInput = document.getElementById('session_id_input');
if (!sessionInput) {
sessionInput = document.createElement('input');
sessionInput.type = 'hidden';
sessionInput.name = 'session_id';
sessionInput.id = 'session_id_input';
form.appendChild(sessionInput);
}
sessionInput.value = sessionId;
// Show modal
showStatusModal();
// Submit form via AJAX
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Upload failed');
}
console.log('Form submitted successfully');
// Don't redirect yet - keep polling until status is complete
})
.catch(error => {
console.error('Form submission error:', error);
if (upload_progress && sessionId) {
upload_progress[sessionId] = {
'status': 'error',
'progress': 0,
'message': 'Upload failed: ' + error.message
};
}
});
}
function showStatusModal() {
console.log("Processing popup triggered");
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
statusModal.show();
// Update status message based on media type
const mediaType = document.getElementById('media_type').value;
{% if system_info %}
// Start system monitoring updates
startModalSystemMonitoring();
{% endif %}
// Start polling progress
pollUploadProgress();
}
function pollUploadProgress() {
const statusMessage = document.getElementById('status-message');
const progressBar = document.getElementById('progress-bar');
let progress = 0;
if (mediaType === 'video') {
statusMessage.textContent = 'Uploading video...';
// Stage 1: Uploading (0-40%)
let uploadInterval = setInterval(() => {
progress += 8;
if (progress >= 40) {
clearInterval(uploadInterval);
progress = 40;
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
// Stage 2: Converting
statusMessage.textContent = 'Converting video to standard format (29.97 fps, 1080p)...';
let convertProgress = 40;
let convertInterval = setInterval(() => {
convertProgress += 3;
progressBar.style.width = `${convertProgress}%`;
progressBar.setAttribute('aria-valuenow', convertProgress);
if (convertProgress >= 100) {
clearInterval(convertInterval);
statusMessage.textContent = 'Video uploaded and converted successfully!';
// Stop system monitoring updates
{% if system_info %}
stopModalSystemMonitoring();
{% endif %}
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
// Poll every 500ms for real-time updates
progressInterval = setInterval(() => {
fetch(`/api/upload_progress/${sessionId}`)
.then(response => response.json())
.then(data => {
console.log('Progress update:', data);
// Update progress bar
progressBar.style.width = `${data.progress}%`;
progressBar.setAttribute('aria-valuenow', data.progress);
// Update status message
statusMessage.textContent = data.message;
// If complete or error, stop polling and enable close button
if (data.status === 'complete' || data.status === 'error') {
clearInterval(progressInterval);
progressInterval = null;
{% if system_info %}
stopModalSystemMonitoring();
{% endif %}
const closeBtn = document.querySelector('[data-bs-dismiss="modal"]');
closeBtn.disabled = false;
// Change progress bar color based on status
if (data.status === 'complete') {
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-success');
// Auto-close after 2 seconds and redirect
setTimeout(() => {
statusModal.hide();
window.location.href = returnUrl;
}, 2000);
} else if (data.status === 'error') {
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
progressBar.classList.add('bg-danger');
}
}, 600);
} else {
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
}
}, 400);
} else {
// Default for other media types
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'pdf':
statusMessage.textContent = 'Converting PDF to 4K images. This may take a while...';
break;
case 'ppt':
statusMessage.textContent = 'Converting PowerPoint to images (PPTX → PDF → Images). This may take 2-5 minutes...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
// Simulate progress updates
let interval = setInterval(() => {
const increment = (mediaType === 'image') ? 20 : 5;
progress += increment;
if (progress >= 100) {
clearInterval(interval);
statusMessage.textContent = 'Files uploaded and processed successfully!';
{% if system_info %}
stopModalSystemMonitoring();
{% endif %}
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
} else {
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
}
}, 500);
}
}
})
.catch(error => {
console.error('Error fetching progress:', error);
statusMessage.textContent = 'Error tracking upload progress';
});
}, 500); // Poll every 500ms
}
{% if system_info %}