updated digiserver 2

This commit is contained in:
ske087
2025-11-12 16:07:03 +02:00
parent 2deb398fd8
commit e5a00d19a5
44 changed files with 2656 additions and 230 deletions

View File

@@ -1,30 +0,0 @@
{% extends "base.html" %}
{% block title %}Add Player - DigiServer v2{% endblock %}
{% block content %}
<h1>Add Player</h1>
<div class="card">
<form method="POST">
<div style="margin-bottom: 1rem;">
<label>Name</label>
<input type="text" name="name" required style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Location (optional)</label>
<input type="text" name="location" style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Group (optional)</label>
<select name="group_id" style="width: 100%; padding: 0.5rem;">
<option value="">No Group</option>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-success">Create Player</button>
<a href="{{ url_for('players.players_list') }}" class="btn">Cancel</a>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Change Password{% endblock %}
{% block content %}
<div class="container" style="max-width: 500px; margin-top: 50px;">
<h2>Change Password</h2>
<form method="POST">
<div class="form-group">
<label>Current Password</label>
<input type="password" name="current_password" class="form-control" required>
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" name="new_password" class="form-control" required>
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View File

@@ -105,7 +105,7 @@
<nav>
{% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('players.players_list') }}">Players</a>
<a href="{{ url_for('players.list') }}">Players</a>
<a href="{{ url_for('groups.groups_list') }}">Groups</a>
<a href="{{ url_for('content.content_list') }}">Content</a>
{% if current_user.is_admin %}

View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Content Library - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Content Library</h1>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
</div>
{% if content_list %}
<div class="card">
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<strong>Total Files:</strong> {{ content_list|length }} |
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for item in content_list %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ item.filename }}</strong>
</td>
<td style="padding: 12px;">
{% if item.content_type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.content_type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.content_type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% elif item.content_type == 'presentation' %}
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ item.duration }}s
</td>
<td style="padding: 12px;">
{{ item.file_size }} MB
</td>
<td style="padding: 12px;">
{% if item.player_count == 0 %}
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
{% else %}
<div style="max-height: 100px; overflow-y: auto;">
{% for player in item.players %}
<div style="margin-bottom: 5px;">
<strong>{{ player.name }}</strong>
{% if player.group %}
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top: 5px;">
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
</span>
</div>
{% endif %}
</td>
<td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</small>
</td>
<td style="padding: 12px;">
{% if item.player_count > 0 %}
{% set first_player = item.players[0] %}
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
class="btn btn-primary btn-sm"
title="Manage Playlist for {{ first_player.name }}"
style="margin-bottom: 5px;">
📝 Manage Playlist
</a>
{% if item.player_count > 1 %}
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
class="btn btn-info btn-sm"
title="View all players with this content">
👥 View All ({{ item.player_count }})
</button>
{% endif %}
{% endif %}
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
class="btn btn-danger btn-sm"
title="Delete this content from all playlists"
style="margin-top: 5px;">
🗑️ Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
</div>
{% endif %}
</div>
<!-- Modal for viewing all players -->
<div id="playersModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
function showAllPlayers(filename, players) {
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
const playersList = document.getElementById('playersList');
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
players.forEach(player => {
playersList.innerHTML += `
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;"><strong>${player.name}</strong></td>
<td style="padding: 10px;">${player.group || '-'}</td>
<td style="padding: 10px;">
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
Manage Playlist
</a>
</td>
</tr>
`;
});
playersList.innerHTML += '</tbody></table>';
document.getElementById('playersModal').style.display = 'block';
}
function closePlayersModal() {
document.getElementById('playersModal').style.display = 'none';
}
function deleteContent(filename) {
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
fetch('/content/delete-by-filename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: filename
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
location.reload();
} else {
alert('Error deleting content: ' + data.message);
}
})
.catch(error => {
alert('Error deleting content: ' + error);
});
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('playersModal');
if (event.target == modal) {
closePlayersModal();
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Content{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Content</h2>
<p>Edit content functionality - placeholder</p>
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,315 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1200px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Upload Content</h1>
</div>
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Target Selection</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target Type:</label>
<select name="target_type" id="target_type" class="form-control" required onchange="updateTargetIdOptions()">
<option value="" disabled selected>Select Target Type</option>
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
</select>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target ID:</label>
<select name="target_id" id="target_id" class="form-control" required>
{% if target_type == 'player' %}
{% for player in players %}
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.name }}</option>
{% endfor %}
{% elif target_type == 'group' %}
{% for group in groups %}
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
{% else %}
<option value="" disabled selected>Select a Target ID</option>
{% endif %}
</select>
</div>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Media Details</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
<option value="image">Image (JPG, PNG, GIF)</option>
<option value="video">Video (MP4, AVI, MOV)</option>
<option value="pdf">PDF Document</option>
<option value="ppt">PowerPoint (PPT/PPTX)</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
Images will be displayed as-is
</small>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long to display each image/slide (videos use actual length)
</small>
</div>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
<small style="color: #6c757d; display: block; margin-top: 5px;">
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
</small>
<div id="file-list" style="margin-top: 10px;"></div>
</div>
</div>
<div style="text-align: center;">
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
📤 Upload Files
</button>
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
← Back
</a>
</div>
</form>
</div>
<!-- Modal for Status Updates -->
<div id="statusModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
<div style="margin-bottom: 20px;">
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
</div>
<!-- Progress Bar -->
<div style="margin-bottom: 30px;">
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
0%
</div>
</div>
</div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
let progressInterval = null;
let sessionId = null;
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
function generateSessionId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function handleFormSubmit(event) {
event.preventDefault();
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;
showStatusModal();
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');
})
.catch(error => {
console.error('Form submission error:', error);
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
document.getElementById('progress-bar').style.background = '#dc3545';
document.getElementById('close-modal-btn').disabled = false;
});
}
function showStatusModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'block';
const mediaType = document.getElementById('media_type').value;
const statusMessage = document.getElementById('status-message');
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'video':
statusMessage.textContent = 'Uploading and converting video. This may take several minutes...';
break;
case 'pdf':
statusMessage.textContent = 'Uploading and converting PDF to images...';
break;
case 'ppt':
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
pollUploadProgress();
}
function closeModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'none';
if (progressInterval) {
clearInterval(progressInterval);
}
window.location.href = returnUrl;
}
function pollUploadProgress() {
progressInterval = setInterval(() => {
fetch(`/api/upload-progress/${sessionId}`)
.then(response => response.json())
.then(data => {
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = `${data.progress}%`;
progressBar.textContent = `${data.progress}%`;
document.getElementById('status-message').textContent = data.message;
if (data.status === 'complete' || data.status === 'error') {
clearInterval(progressInterval);
progressInterval = null;
const closeBtn = document.getElementById('close-modal-btn');
closeBtn.disabled = false;
if (data.status === 'complete') {
progressBar.style.background = '#28a745';
setTimeout(() => closeModal(), 2000);
} else if (data.status === 'error') {
progressBar.style.background = '#dc3545';
}
}
})
.catch(error => console.error('Error fetching progress:', error));
}, 500);
}
function updateTargetIdOptions() {
const targetType = document.getElementById('target_type').value;
const targetIdSelect = document.getElementById('target_id');
targetIdSelect.innerHTML = '';
if (targetType === 'player') {
const players = {{ players|tojson }};
players.forEach(player => {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.name;
targetIdSelect.appendChild(option);
});
} else if (targetType === 'group') {
const groups = {{ groups|tojson }};
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
targetIdSelect.appendChild(option);
});
}
}
function handleMediaTypeChange() {
const mediaType = document.getElementById('media_type').value;
const hint = document.getElementById('media-type-hint');
switch(mediaType) {
case 'image':
hint.textContent = 'Images will be displayed as-is';
break;
case 'video':
hint.textContent = 'Videos will be converted to optimized format';
break;
case 'pdf':
hint.textContent = 'PDF will be converted to images (one per page)';
break;
case 'ppt':
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
break;
}
}
function handleFileChange() {
const filesInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const mediaType = document.getElementById('media_type').value;
const durationInput = document.getElementById('duration');
fileList.innerHTML = '';
if (filesInput.files.length > 0) {
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
for (let i = 0; i < filesInput.files.length; i++) {
const file = filesInput.files[i];
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
}
fileList.innerHTML += '</ul>';
}
if (mediaType === 'video' && filesInput.files.length > 0) {
const file = filesInput.files[0];
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.round(video.duration);
durationInput.value = duration;
};
video.src = URL.createObjectURL(file);
}
}
</script>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Content - DigiServer v2{% endblock %}
{% block content %}
<h1>Content</h1>
<div class="card">
<p>Content list view - Template in progress</p>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">Upload Content</a>
</div>
{% endblock %}

View File

@@ -9,7 +9,7 @@
<div class="card">
<h3 style="color: #3498db; margin-bottom: 0.5rem;">👥 Players</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
<a href="{{ url_for('players.players_list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
<a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
</div>
<div class="card">

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}403 - Access Denied{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #ffc107;">403</h1>
<h2>Access Denied</h2>
<p style="color: #6c757d; margin: 20px 0;">You don't have permission to access this resource.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}404 - Page Not Found{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #dc3545;">404</h1>
<h2>Page Not Found</h2>
<p style="color: #6c757d; margin: 20px 0;">The page you're looking for doesn't exist.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}500 - Server Error{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #dc3545;">500</h1>
<h2>Internal Server Error</h2>
<p style="color: #6c757d; margin: 20px 0;">Something went wrong on our end. Please try again later.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% 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

@@ -0,0 +1,10 @@
{% 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

@@ -0,0 +1,11 @@
{% 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

@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Add Player - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 800px; margin-top: 2rem;">
<h1>Add New Player</h1>
<p style="color: #6c757d; margin-bottom: 2rem;">
Create a new digital signage player with authentication credentials
</p>
<div class="card">
<form method="POST">
<h3 style="margin-top: 0; border-bottom: 2px solid #007bff; padding-bottom: 0.5rem;">
Basic Information
</h3>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Display Name *</label>
<input type="text" name="name" required
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., Office Reception Player">
<small style="color: #6c757d;">Friendly name for the player</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Hostname *</label>
<input type="text" name="hostname" required
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., office-player-001">
<small style="color: #6c757d;">
Unique identifier for this player (must match screen_name in player config)
</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Location</label>
<input type="text" name="location"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., Main Office - Reception Area">
<small style="color: #6c757d;">Physical location of the player (optional)</small>
</div>
<h3 style="margin-top: 2rem; border-bottom: 2px solid #28a745; padding-bottom: 0.5rem;">
Authentication
</h3>
<p style="color: #6c757d; font-size: 0.9rem; margin-bottom: 1rem;">
Choose one authentication method (Quick Connect recommended for easy setup)
</p>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Password</label>
<input type="password" name="password" id="password"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="Leave empty to use Quick Connect only">
<small style="color: #6c757d;">
Secure password for player authentication (optional if using Quick Connect)
</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Quick Connect Code *</label>
<input type="text" name="quickconnect_code" required
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., OFFICE123">
<small style="color: #6c757d;">
Easy pairing code for quick setup (must match quickconnect_key in player config)
</small>
</div>
<h3 style="margin-top: 2rem; border-bottom: 2px solid #ffc107; padding-bottom: 0.5rem;">
Display Settings
</h3>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Orientation</label>
<select name="orientation" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="Landscape" selected>Landscape</option>
<option value="Portrait">Portrait</option>
</select>
<small style="color: #6c757d;">Display orientation for the player</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Assign to Group</label>
<select name="group_id" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="">No Group (Unassigned)</option>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
<small style="color: #6c757d;">Assign player to a content group (optional)</small>
</div>
<div style="background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; margin: 2rem 0;">
<h4 style="margin-top: 0; color: #007bff;">📋 Setup Instructions</h4>
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li>Create the player with the form above</li>
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
<li>Configure the player's <code>app_config.json</code> with:
<ul style="margin-top: 0.5rem;">
<li><code>server_ip</code>: Your server address</li>
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
</ul>
</li>
<li>Start the player - it will authenticate automatically</li>
</ol>
</div>
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
✓ Create Player
</button>
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
Cancel
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Player{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Player</h2>
<p>Edit player functionality - placeholder</p>
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">Back to Players</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Player Fullscreen{% endblock %}
{% block content %}
<div class="container">
<h2>Player Fullscreen View</h2>
<p>Fullscreen player view - placeholder</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,331 @@
{% extends "base.html" %}
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1400px;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h1>{{ player.name }}</h1>
<div style="margin-top: 10px;">
{% if status_info.online %}
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
⚫ Offline
</span>
{% endif %}
<span style="color: #6c757d; font-size: 14px;">
Last seen: {{ status_info.last_seen_ago }}
</span>
</div>
</div>
<div>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
✏️ Edit Player
</a>
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" class="btn btn-success">
📤 Upload Content
</a>
<a href="{{ url_for('players.list') }}" class="btn">
← Back to Players
</a>
</div>
</div>
<!-- Main Content Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- Player Information Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📋 Player Information
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
<td style="padding: 10px;">{{ player.name }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
<td style="padding: 10px;">
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Location:</td>
<td style="padding: 10px;">{{ player.location or '-' }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Group:</td>
<td style="padding: 10px;">
{% if player.group %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
{{ player.group.name }}
</span>
{% else %}
<span style="color: #6c757d;">No group</span>
{% endif %}
</td>
</tr>
<tr>
<td style="padding: 10px; font-weight: bold;">Created:</td>
<td style="padding: 10px;">{{ player.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
</table>
</div>
<!-- Authentication Details Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
🔐 Authentication Details
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
<td style="padding: 10px;">
{% if player.password_hash %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
<td style="padding: 10px;">
{% if player.quickconnect_code %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
<td style="padding: 10px;">
{% if player.auth_code %}
<span style="color: #28a745;">✓ Yes</span>
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
🔄 Regenerate
</button>
</form>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 10px;">
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary" style="width: 100%; text-align: center;">
✏️ Edit Authentication Settings
</a>
</td>
</tr>
</table>
</div>
</div>
<!-- Playlist Management Card -->
<div class="card" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">🎬 Current Playlist</h3>
<div>
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}"
class="btn btn-success btn-sm">
+ Add Content
</a>
</div>
</div>
{% if playlist %}
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<strong>Total Items:</strong> {{ playlist|length }} |
<strong>Total Duration:</strong> {% set total_duration = namespace(value=0) %}{% for item in playlist %}{% set total_duration.value = total_duration.value + (item.duration or 10) %}{% endfor %}{{ total_duration.value }}s
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; width: 50px;">Order</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody id="playlist-items">
{% for item in playlist %}
<tr style="border-bottom: 1px solid #dee2e6;" data-content-id="{{ item.id }}">
<td style="padding: 10px; text-align: center;">
<strong>{{ loop.index }}</strong>
</td>
<td style="padding: 10px;">
{{ item.filename }}
</td>
<td style="padding: 10px;">
{% if item.type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ item.duration or 10 }}s
</td>
<td style="padding: 10px;">
<button onclick="moveUp({{ item.id }})" class="btn btn-sm"
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
{% if loop.first %}disabled{% endif %}>
</button>
<button onclick="moveDown({{ item.id }})" class="btn btn-sm"
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
{% if loop.last %}disabled{% endif %}>
</button>
<button onclick="removeFromPlaylist({{ item.id }}, '{{ item.filename }}')"
class="btn btn-danger btn-sm" style="padding: 3px 8px;">
🗑️ Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center;">
⚠️ No content in playlist. <a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" style="color: #856404; text-decoration: underline;">Upload content</a> to get started.
</div>
{% endif %}
</div>
<!-- Player Activity Log Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📊 Recent Activity & Feedback
</h3>
{% if recent_feedback %}
<div style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: white;">
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
</tr>
</thead>
<tbody>
{% for feedback in recent_feedback %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; white-space: nowrap;">
<small style="color: #6c757d;">{{ feedback.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</td>
<td style="padding: 10px;">
{% if feedback.status == 'playing' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
{% elif feedback.status == 'idle' %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
{% elif feedback.status == 'error' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
{% else %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ feedback.message or '-' }}
</td>
<td style="padding: 10px;">
{% if feedback.error %}
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
No activity logs yet. The player will send feedback once it starts playing content.
</div>
{% endif %}
</div>
</div>
<script>
function moveUp(contentId) {
updatePlaylistOrder(contentId, 'up');
}
function moveDown(contentId) {
updatePlaylistOrder(contentId, 'down');
}
function updatePlaylistOrder(contentId, direction) {
fetch('/players/{{ player.id }}/playlist/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content_id: contentId,
direction: direction
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error reordering playlist: ' + data.message);
}
})
.catch(error => {
alert('Error reordering playlist: ' + error);
});
}
function removeFromPlaylist(contentId, filename) {
if (confirm(`Remove "${filename}" from this player's playlist?`)) {
fetch('/players/{{ player.id }}/playlist/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content_id: contentId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error removing content: ' + data.message);
}
})
.catch(error => {
alert('Error removing content: ' + error);
});
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Players - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Players</h1>
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">+ Add New Player</a>
</div>
{% if players %}
<div class="card">
<table style="width: 100%; border-collapse: collapse;">
<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;">Group</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>
</thead>
<tbody>
{% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ player.name }}</strong>
</td>
<td style="padding: 12px;">
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{{ player.hostname }}</code>
</td>
<td style="padding: 12px;">
{{ player.location or '-' }}
</td>
<td style="padding: 12px;">
{% if player.group %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ player.group.name }}</span>
{% else %}
<span style="color: #6c757d;">No group</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ player.orientation or 'Landscape' }}
</td>
<td style="padding: 12px;">
{% 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>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Offline</span>
{% endif %}
</td>
<td style="padding: 12px;">
{% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span style="color: #6c757d;">Never</span>
{% endif %}
</td>
<td style="padding: 12px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-info btn-sm" title="View" style="margin-right: 5px;">
👁️ View
</a>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary btn-sm" title="Edit" style="margin-right: 5px;">
✏️ Edit
</a>
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" title="Fullscreen" target="_blank" style="margin-right: 5px;">
⛶ Full
</a>
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete player \'{{ player.name }}\'?');">
<button type="submit" class="btn btn-danger btn-sm" title="Delete">
🗑️ Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</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>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Players - DigiServer v2{% endblock %}
{% block content %}
<h1>Players</h1>
<div class="card">
<p>Players list view - Template in progress</p>
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">Add New Player</a>
</div>
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<h1>Upload Content</h1>
<div class="card">
<form method="POST" enctype="multipart/form-data">
<div style="margin-bottom: 1rem;">
<label>File</label>
<input type="file" name="file" required style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Duration (seconds, for images)</label>
<input type="number" name="duration" value="10" min="1" 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">Upload</button>
<a href="{{ url_for('content.content_list') }}" class="btn">Cancel</a>
</form>
</div>
{% endblock %}