Final: Complete modernization - Option 1 deployment, unified persistence, migration scripts
- Implement Docker image-based deployment (Option 1) * Code immutable in image, no volume override * Eliminated init-data.sh manual step * Simplified deployment process - Unified persistence in data/ folder * Moved nginx.conf and nginx-custom-domains.conf to data/ * All runtime configs and data in single location * Clear separation: repo (source) vs data/ (runtime) - Archive legacy features * Groups blueprint and templates removed * Legacy playlist routes redirected to content area * Organized in old_code_documentation/ - Added network migration support * New migrate_network.sh script for IP changes * Regenerates SSL certs for new IP * Updates database configuration * Tested workflow: clone → deploy → migrate - Enhanced deploy.sh * Creates data directories * Copies nginx configs from repo to data/ * Validates file existence before deployment * Prevents incomplete deployments - Updated documentation * QUICK_DEPLOYMENT.md shows 4-step workflow * Complete deployment workflow documented * Migration procedures included - Production ready deployment workflow: 1. Clone & setup (.env configuration) 2. Deploy (./deploy.sh) 3. Migrate network (./migrate_network.sh if needed) 4. Normal operations (docker compose restart)
This commit is contained in:
@@ -97,6 +97,67 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Duration spinner control */
|
||||
.duration-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.duration-display {
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
padding: 6px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.duration-spinner button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.duration-spinner button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.duration-spinner button:active {
|
||||
background: #e0e0e0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.duration-spinner button.btn-increase {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.duration-spinner button.btn-decrease {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.audio-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.audio-checkbox {
|
||||
display: none;
|
||||
}
|
||||
@@ -154,6 +215,36 @@
|
||||
body.dark-mode .available-content {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Dark mode for duration spinner */
|
||||
body.dark-mode .duration-display {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button {
|
||||
background: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button:hover {
|
||||
background: #4a5568;
|
||||
border-color: #718096;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button:active {
|
||||
background: #5a6a78;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button.btn-increase {
|
||||
color: #48bb78;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-spinner button.btn-decrease {
|
||||
color: #f56565;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container" style="max-width: 1400px;">
|
||||
@@ -230,7 +321,27 @@
|
||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||
{% else %}📁 Other{% endif %}
|
||||
</td>
|
||||
<td>{{ content._playlist_duration or content.duration }}s</td>
|
||||
<td>
|
||||
<div class="duration-spinner">
|
||||
<button type="button"
|
||||
class="btn-decrease"
|
||||
onclick="event.stopPropagation(); changeDuration({{ content.id }}, -1)"
|
||||
onmousedown="event.stopPropagation()"
|
||||
title="Decrease duration by 1 second">
|
||||
⬇️
|
||||
</button>
|
||||
<div class="duration-display" id="duration-display-{{ content.id }}">
|
||||
{{ content._playlist_duration or content.duration }}s
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn-increase"
|
||||
onclick="event.stopPropagation(); changeDuration({{ content.id }}, 1)"
|
||||
onmousedown="event.stopPropagation()"
|
||||
title="Increase duration by 1 second">
|
||||
⬆️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if content.content_type == 'video' %}
|
||||
<label class="audio-toggle">
|
||||
@@ -413,6 +524,58 @@ function saveOrder() {
|
||||
});
|
||||
}
|
||||
|
||||
// Change duration with spinner buttons
|
||||
function changeDuration(contentId, change) {
|
||||
const displayElement = document.getElementById(`duration-display-${contentId}`);
|
||||
const currentText = displayElement.textContent;
|
||||
const currentDuration = parseInt(currentText);
|
||||
const newDuration = currentDuration + change;
|
||||
|
||||
// Validate duration (minimum 1 second)
|
||||
if (newDuration < 1) {
|
||||
alert('Duration must be at least 1 second');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update display immediately for visual feedback
|
||||
displayElement.style.opacity = '0.7';
|
||||
displayElement.textContent = newDuration + 's';
|
||||
|
||||
// Save to server
|
||||
const playlistId = {{ playlist.id }};
|
||||
const url = `/content/playlist/${playlistId}/update-duration/${contentId}`;
|
||||
const formData = new FormData();
|
||||
formData.append('duration', newDuration);
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Duration updated successfully');
|
||||
displayElement.style.opacity = '1';
|
||||
displayElement.style.color = '#28a745';
|
||||
setTimeout(() => {
|
||||
displayElement.style.color = '';
|
||||
}, 1000);
|
||||
} else {
|
||||
// Revert on error
|
||||
displayElement.textContent = currentDuration + 's';
|
||||
displayElement.style.opacity = '1';
|
||||
alert('Error updating duration: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Revert on error
|
||||
displayElement.textContent = currentDuration + 's';
|
||||
displayElement.style.opacity = '1';
|
||||
console.error('Error:', error);
|
||||
alert('Error updating duration');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAudio(contentId, enabled) {
|
||||
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||||
const playlistId = {{ playlist.id }};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Group - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Create Group</h1>
|
||||
<div class="card">
|
||||
<form method="POST">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label>Group Name</label>
|
||||
<input type="text" name="name" required style="width: 100%; padding: 0.5rem;">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label>Description (optional)</label>
|
||||
<textarea name="description" rows="3" style="width: 100%; padding: 0.5rem;"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Create Group</button>
|
||||
<a href="{{ url_for('groups.groups_list') }}" class="btn">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Group{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Edit Group</h2>
|
||||
<p>Edit group functionality - placeholder</p>
|
||||
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,10 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Group Fullscreen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Group Fullscreen View</h2>
|
||||
<p>Fullscreen group view - placeholder</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Groups - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Groups</h1>
|
||||
<div class="card">
|
||||
<p>Groups list view - Template in progress</p>
|
||||
<a href="{{ url_for('groups.create_group') }}" class="btn btn-success">Create New Group</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Group{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Manage Group</h2>
|
||||
<p>Manage group functionality - placeholder</p>
|
||||
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,857 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.playlist-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.player-info-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.player-info-card h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.player-info-card p {
|
||||
margin: 5px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.playlist-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.draggable-row {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.draggable-row.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-content-form {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 70px !important;
|
||||
padding: 5px 8px !important;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
background: white !important;
|
||||
border: 2px solid #ced4da !important;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.duration-input:hover {
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.duration-input:focus {
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||||
background: white !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.content-type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-image {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.badge-video {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.badge-pdf {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-input {
|
||||
background: #1a202c !important;
|
||||
border-color: #4a5568 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
body.dark-mode .duration-input:hover {
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
body.dark-mode .duration-input:focus {
|
||||
background: #2d3748 !important;
|
||||
border-color: #667eea !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
body.dark-mode .badge-image {
|
||||
background: #1e3a5f;
|
||||
color: #64b5f6;
|
||||
}
|
||||
|
||||
body.dark-mode .badge-video {
|
||||
background: #4a1e5a;
|
||||
color: #ce93d8;
|
||||
}
|
||||
|
||||
body.dark-mode .badge-pdf {
|
||||
background: #5a1e1e;
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
/* Audio toggle styles */
|
||||
.audio-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.audio-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-label {
|
||||
font-size: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.audio-checkbox + .audio-label .audio-on {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-checkbox + .audio-label .audio-off {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.audio-checkbox:checked + .audio-label .audio-on {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.audio-checkbox:checked + .audio-label .audio-off {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-label:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="playlist-container">
|
||||
<!-- Player Info Card -->
|
||||
<div class="player-info-card">
|
||||
<h1>🎬 {{ player.name }}</h1>
|
||||
<p>📍 {{ player.location or 'No location' }}</p>
|
||||
<p>🖥️ Hostname: {{ player.hostname }}</p>
|
||||
<p>📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Items</div>
|
||||
<div class="stat-value">{{ playlist_content|length }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Version</div>
|
||||
<div class="stat-value">{{ player.playlist_version }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Total Duration</div>
|
||||
<div class="stat-value">{{ playlist_content|sum(attribute='duration') }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}" class="btn btn-secondary">
|
||||
← Back to Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.upload_content', player_id=player.id, return_url=url_for('playlist.manage_playlist', player_id=player.id)) }}"
|
||||
class="btn btn-success">
|
||||
➕ Upload New Content
|
||||
</a>
|
||||
{% if playlist_content %}
|
||||
<form method="POST" action="{{ url_for('playlist.clear_playlist', player_id=player.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to clear the entire playlist?');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
🗑️ Clear Playlist
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Current Playlist -->
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>📋 Current Playlist</h2>
|
||||
<span style="color: #999; font-size: 14px;">
|
||||
Drag and drop to reorder
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Filename</th>
|
||||
<th style="width: 100px;">Type</th>
|
||||
<th style="width: 120px;">Duration (s)</th>
|
||||
<th style="width: 80px;">Audio</th>
|
||||
<th style="width: 100px;">Size</th>
|
||||
<th style="width: 150px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-tbody">
|
||||
{% for content in playlist_content %}
|
||||
<tr class="draggable-row" data-content-id="{{ content.id }}">
|
||||
<td>
|
||||
<span class="drag-handle" draggable="true">⋮⋮</span>
|
||||
</td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
<td>
|
||||
{% if content.content_type == 'image' %}
|
||||
<span class="content-type-badge badge-image">📷 Image</span>
|
||||
{% elif content.content_type == 'video' %}
|
||||
<span class="content-type-badge badge-video">🎥 Video</span>
|
||||
{% elif content.content_type == 'pdf' %}
|
||||
<span class="content-type-badge badge-pdf">📄 PDF</span>
|
||||
{% else %}
|
||||
<span class="content-type-badge">📁 {{ content.content_type }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<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 }})">
|
||||
<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;"
|
||||
title="Save duration (or press Enter)">
|
||||
💾
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if content.content_type == 'video' %}
|
||||
<label class="audio-toggle" onclick="event.stopPropagation()">
|
||||
<input type="checkbox"
|
||||
class="audio-checkbox"
|
||||
data-content-id="{{ content.id }}"
|
||||
{{ 'checked' if not content._playlist_muted else '' }}
|
||||
onchange="toggleAudio({{ content.id }}, this.checked)"
|
||||
onclick="event.stopPropagation()">
|
||||
<span class="audio-label">
|
||||
<span class="audio-on">🔊</span>
|
||||
<span class="audio-off">🔇</span>
|
||||
</span>
|
||||
</label>
|
||||
{% else %}
|
||||
<span style="color: #999;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('playlist.remove_from_playlist', player_id=player.id, content_id=content.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Remove {{ content.filename }} from playlist?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
✕ Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<h3>No content in playlist</h3>
|
||||
<p>Upload content or add existing files to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Add Content Section -->
|
||||
{% if available_content %}
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>➕ Add Existing Content</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
|
||||
class="add-content-form">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="duration">Display Duration (seconds):</label>
|
||||
<input type="number"
|
||||
name="duration"
|
||||
id="duration"
|
||||
class="form-control"
|
||||
value="10"
|
||||
min="1"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
➕ Add to Playlist
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let draggedElement = null;
|
||||
|
||||
// Initialize drag and drop
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
// 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('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) {
|
||||
// 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) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
if (e.stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (draggedElement !== this) {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
const allRows = [...tbody.querySelectorAll('.draggable-row')];
|
||||
const draggedIndex = allRows.indexOf(draggedElement);
|
||||
const targetIndex = allRows.indexOf(this);
|
||||
|
||||
if (draggedIndex < targetIndex) {
|
||||
this.parentNode.insertBefore(draggedElement, this.nextSibling);
|
||||
} else {
|
||||
this.parentNode.insertBefore(draggedElement, this);
|
||||
}
|
||||
|
||||
updateRowNumbers();
|
||||
saveOrder();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
this.classList.remove('dragging');
|
||||
}
|
||||
|
||||
function updateRowNumbers() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody tr');
|
||||
rows.forEach((row, index) => {
|
||||
row.querySelector('td:nth-child(2)').textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function saveOrder() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
|
||||
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
|
||||
|
||||
fetch('{{ url_for("playlist.reorder_playlist", player_id=player.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content_ids: contentIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Playlist reordered successfully');
|
||||
} else {
|
||||
alert('Error reordering playlist: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error reordering playlist');
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const playerId = {{ player.id }};
|
||||
const url = `/playlist/${playerId}/update-duration/${contentId}`;
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Duration updated successfully');
|
||||
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';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAudio(contentId, enabled) {
|
||||
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||||
const playerId = {{ player.id }};
|
||||
const url = `/playlist/${playerId}/update-muted/${contentId}`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('muted', muted ? 'true' : 'false');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
|
||||
} else {
|
||||
alert('Error updating audio setting: ' + data.message);
|
||||
// Revert checkbox on error
|
||||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||
if (checkbox) checkbox.checked = !enabled;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating audio setting');
|
||||
// Revert checkbox on error
|
||||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||
if (checkbox) checkbox.checked = !enabled;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user