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:
Deployment System
2026-01-17 10:30:42 +02:00
parent d235c8e057
commit 49393d9a73
30 changed files with 1646 additions and 112 deletions

View File

@@ -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 }};

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}