Compare commits

..

19 Commits

Author SHA1 Message Date
DigiServer Developer
328edebe3c Add comprehensive edited media management with expandable cards UI
- Optimized delete modal for light/dark modes with modern gradients
- Added edit_count tracking to media library with warnings in delete confirmation
- Enhanced PlayerEdit model with CASCADE delete on foreign keys
- Improved player management page to show latest 3 edited files with image previews
- Created new edited_media route and template with full version history
- Implemented horizontal expandable cards with two-column layout
- Added interactive version selection with thumbnail grid
- Included original file in versions list with source badge
- Fixed deletion workflow to clean up PlayerEdit records and edited_media folders
- Enhanced UI with smooth animations, hover effects, and dark mode support
2025-12-06 19:17:48 +02:00
DigiServer Developer
ff14e8defb moved some files 2025-12-06 00:09:04 +02:00
DigiServer Developer
8d52c0338f Add player media editing feature with versioning
- Added PlayerEdit model to track edited media history
- Created /api/player-edit-media endpoint for receiving edited files from players
- Implemented versioned storage: edited_media/<content_id>/<filename_vN.ext>
- Automatic playlist update when edited media is received
- Updated content.filename to reference versioned file in playlist
- Added 'Edited Media on the Player' card to player management page
- UI shows version history grouped by original file
- Each edit preserves previous versions in archive folder
- Includes dark mode support for new UI elements
- Documentation: PLAYER_EDIT_MEDIA_API.md
2025-12-06 00:06:11 +02:00
DigiServer Developer
3921a09c4e Fix production config: use simple cache instead of Redis, fix Gunicorn to use production config 2025-12-05 21:46:31 +02:00
DigiServer Developer
8e43f2bd42 Add edit_on_player_enabled feature for playlist content
- Added edit_on_player_enabled column to playlist_content table
- Updated playlist model to track edit enablement per content item
- Added UI checkbox on upload media page to enable/disable editing
- Added toggle column on manage playlist page for existing content
- Updated API endpoint to return edit_on_player_enabled flag to players
- Fixed docker-entrypoint.sh to use production config
- Supports PDF, Images, and PPTX content types
2025-12-05 21:31:04 +02:00
d395240dce Apply timezone fix to all templates - convert UTC to local time 2025-11-28 15:51:10 +02:00
1fce23d3fd Fix timezone display: Convert UTC to local time in players list 2025-11-28 15:22:42 +02:00
610227457c Fix player status display on players list page 2025-11-28 15:12:36 +02:00
DigiServer Developer
f88e332186 updated playlis version 2025-11-26 20:01:16 +02:00
38929da929 Add emoji font support to Docker image for Raspberry Pi deployment 2025-11-25 08:20:46 +02:00
DigiServer Developer
b1dbacc679 updated upload page 2025-11-24 23:01:16 +02:00
DigiServer Developer
69562fbf22 updated to fullscreen view ppt 2025-11-24 22:28:44 +02:00
DigiServer Developer
561b364022 updated view for mobil optimization 2025-11-22 18:43:27 +02:00
DigiServer Developer
b73e10cde7 updated for beter media management 2025-11-22 18:24:40 +02:00
DigiServer Developer
f4df930d82 updated to delete player and edit fields 2025-11-21 22:51:28 +02:00
DigiServer Developer
a2281e90e7 updated view and playlist management 2025-11-20 20:46:09 +02:00
DigiServer Developer
78c83579ee doker updated jor libre install 2025-11-20 19:59:49 +02:00
DigiServer Developer
efb63f2b3f updating libre install 2025-11-20 19:44:07 +02:00
DigiServer Developer
4d411b645d fixed login 2025-11-17 23:12:53 +02:00
38 changed files with 3403 additions and 152 deletions

View File

@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y \
ffmpeg \ ffmpeg \
libmagic1 \ libmagic1 \
sudo \ sudo \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching # Copy requirements first for better caching
@@ -41,6 +42,8 @@ EXPOSE 5000
# Create a non-root user and grant sudo access for dependency installation # Create a non-root user and grant sudo access for dependency installation
RUN useradd -m -u 1000 appuser && \ RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app /docker-entrypoint.sh && \ chown -R appuser:appuser /app /docker-entrypoint.sh && \
echo "Defaults:appuser !requiretty, !use_pty" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /usr/bin/apt-get" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers && \ echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers && \
echo "appuser ALL=(ALL) NOPASSWD: /app/install_emoji_fonts.sh" >> /etc/sudoers && \ echo "appuser ALL=(ALL) NOPASSWD: /app/install_emoji_fonts.sh" >> /etc/sudoers && \
chmod +x /app/install_libreoffice.sh /app/install_emoji_fonts.sh chmod +x /app/install_libreoffice.sh /app/install_emoji_fonts.sh

33
add_muted_column.py Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Add muted column to playlist_content table."""
from app.app import create_app
from app.extensions import db
def add_muted_column():
"""Add muted column to playlist_content association table."""
app = create_app()
with app.app_context():
try:
# Check if column already exists
result = db.session.execute(db.text("PRAGMA table_info(playlist_content)")).fetchall()
columns = [row[1] for row in result]
if 'muted' in columns:
print(" Column 'muted' already exists in playlist_content table")
return
# Add muted column with default value True (muted by default)
db.session.execute(db.text("""
ALTER TABLE playlist_content
ADD COLUMN muted BOOLEAN DEFAULT TRUE
"""))
db.session.commit()
print("✅ Successfully added 'muted' column to playlist_content table")
print(" Default: TRUE (videos will be muted by default)")
except Exception as e:
db.session.rollback()
print(f"❌ Error adding column: {e}")
if __name__ == '__main__':
add_muted_column()

View File

@@ -52,6 +52,7 @@ def create_app(config_name=None):
register_error_handlers(app) register_error_handlers(app)
register_commands(app) register_commands(app)
register_context_processors(app) register_context_processors(app)
register_template_filters(app)
return app return app
@@ -181,6 +182,34 @@ def register_context_processors(app):
return {'theme': theme} return {'theme': theme}
def register_template_filters(app):
"""Register custom Jinja2 template filters"""
from datetime import datetime, timezone
@app.template_filter('localtime')
def localtime_filter(dt, format='%Y-%m-%d %H:%M'):
"""Convert UTC datetime to local time and format it.
Args:
dt: datetime object in UTC
format: strftime format string
Returns:
Formatted datetime string in local timezone
"""
if dt is None:
return ''
# If datetime is naive (no timezone), assume it's UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# Convert to local time
local_dt = dt.astimezone()
return local_dt.strftime(format)
# For backwards compatibility and direct running # For backwards compatibility and direct running
if __name__ == '__main__': if __name__ == '__main__':
app = create_app() app = create_app()

View File

@@ -409,7 +409,7 @@ def delete_leftover_images():
try: try:
# Find all leftover image content # Find all leftover image content
leftover_images = db.session.query(Content).filter( leftover_images = db.session.query(Content).filter(
Content.media_type == 'image', Content.content_type == 'image',
~Content.id.in_( ~Content.id.in_(
db.session.query(playlist_content.c.content_id) db.session.query(playlist_content.c.content_id)
) )
@@ -421,16 +421,26 @@ def delete_leftover_images():
for content in leftover_images: for content in leftover_images:
try: try:
# Delete physical file # Delete physical file
if content.file_path: if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path) file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record # Delete database record
db.session.delete(content) db.session.delete(content)
deleted_count += 1 deleted_count += 1
except Exception as e: except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}") errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit() db.session.commit()
@@ -455,7 +465,7 @@ def delete_leftover_videos():
try: try:
# Find all leftover video content # Find all leftover video content
leftover_videos = db.session.query(Content).filter( leftover_videos = db.session.query(Content).filter(
Content.media_type == 'video', Content.content_type == 'video',
~Content.id.in_( ~Content.id.in_(
db.session.query(playlist_content.c.content_id) db.session.query(playlist_content.c.content_id)
) )
@@ -467,16 +477,26 @@ def delete_leftover_videos():
for content in leftover_videos: for content in leftover_videos:
try: try:
# Delete physical file # Delete physical file
if content.file_path: if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path) file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record # Delete database record
db.session.delete(content) db.session.delete(content)
deleted_count += 1 deleted_count += 1
except Exception as e: except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}") errors.append(f"Error deleting {content.filename}: {str(e)}")
db.session.commit() db.session.commit()
@@ -500,16 +520,26 @@ def delete_single_leftover(content_id):
content = Content.query.get_or_404(content_id) content = Content.query.get_or_404(content_id)
# Delete physical file # Delete physical file
if content.file_path: if content.filename:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path) file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
PlayerEdit.query.filter_by(content_id=content.id).delete()
# Delete database record # Delete database record
db.session.delete(content) db.session.delete(content)
db.session.commit() db.session.commit()
flash(f'Successfully deleted {content.file_path}', 'success') flash(f'Successfully deleted {content.filename}', 'success')
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@@ -615,7 +645,7 @@ def install_libreoffice():
flash('Installation script not found', 'danger') flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies')) return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', 'bash', script_path], result = subprocess.run(['sudo', '-n', script_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=300) timeout=300)
@@ -652,7 +682,7 @@ def install_emoji_fonts():
flash('Installation script not found', 'danger') flash('Installation script not found', 'danger')
return redirect(url_for('admin.dependencies')) return redirect(url_for('admin.dependencies'))
result = subprocess.run(['sudo', 'bash', script_path], result = subprocess.run(['sudo', '-n', script_path],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=180) timeout=180)

View File

@@ -96,7 +96,7 @@ def health_check():
@api_bp.route('/auth/player', methods=['POST']) @api_bp.route('/auth/player', methods=['POST'])
@rate_limit(max_requests=10, window=60) @rate_limit(max_requests=120, window=60)
def authenticate_player(): def authenticate_player():
"""Authenticate a player and return auth code and configuration. """Authenticate a player and return auth code and configuration.
@@ -152,7 +152,7 @@ def authenticate_player():
@api_bp.route('/auth/verify', methods=['POST']) @api_bp.route('/auth/verify', methods=['POST'])
@rate_limit(max_requests=30, window=60) @rate_limit(max_requests=300, window=60)
def verify_auth_code(): def verify_auth_code():
"""Verify an auth code and return player information. """Verify an auth code and return player information.
@@ -240,8 +240,8 @@ def get_playlist_by_quickconnect():
'playlist_version': 0 'playlist_version': 0
}), 403 }), 403
# Check if quickconnect matches # Check if quickconnect matches (using bcrypt verification)
if player.quickconnect_code != quickconnect_code: if not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect code for player: {hostname}') log_action('warning', f'Invalid quickconnect code for player: {hostname}')
return jsonify({ return jsonify({
'error': 'Invalid quickconnect code', 'error': 'Invalid quickconnect code',
@@ -293,7 +293,7 @@ def get_playlist_by_quickconnect():
@api_bp.route('/playlists/<int:player_id>', methods=['GET']) @api_bp.route('/playlists/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=30, window=60) @rate_limit(max_requests=300, window=60)
@verify_player_auth @verify_player_auth
def get_player_playlist(player_id: int): def get_player_playlist(player_id: int):
"""Get playlist for a specific player. """Get playlist for a specific player.
@@ -401,14 +401,15 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
'duration': content._playlist_duration or content.duration or 10, 'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx, 'position': content._playlist_position or idx,
'url': content_url, # Full URL for downloads 'url': content_url, # Full URL for downloads
'description': content.description 'description': content.description,
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
}) })
return playlist_data return playlist_data
@api_bp.route('/player-feedback', methods=['POST']) @api_bp.route('/player-feedback', methods=['POST'])
@rate_limit(max_requests=100, window=60) @rate_limit(max_requests=600, window=60)
def receive_player_feedback(): def receive_player_feedback():
"""Receive feedback/status updates from players (Kivy player compatible). """Receive feedback/status updates from players (Kivy player compatible).
@@ -427,24 +428,50 @@ def receive_player_feedback():
data = request.json data = request.json
if not data: if not data:
log_action('warning', 'Player feedback received with no data')
return jsonify({'error': 'No data provided'}), 400 return jsonify({'error': 'No data provided'}), 400
player_name = data.get('player_name') player_name = data.get('player_name')
hostname = data.get('hostname') # Also accept hostname
quickconnect_code = data.get('quickconnect_code') quickconnect_code = data.get('quickconnect_code')
if not player_name or not quickconnect_code: # Find player by hostname first (more reliable), then by name
return jsonify({'error': 'player_name and quickconnect_code required'}), 400 player = None
if hostname:
player = Player.query.filter_by(hostname=hostname).first()
if not player and player_name:
player = Player.query.filter_by(name=player_name).first()
# Find player by name and validate quickconnect # If player not found and no credentials provided, try to infer from IP and recent auth
player = Player.query.filter_by(name=player_name).first() if not player and (not quickconnect_code or (not player_name and not hostname)):
# Try to find player by recent authentication from same IP
client_ip = request.remote_addr
# Look for players with matching IP in recent activity (last 5 minutes)
recent_time = datetime.utcnow() - timedelta(minutes=5)
possible_player = Player.query.filter(
Player.last_seen >= recent_time
).order_by(Player.last_seen.desc()).first()
if possible_player:
player = possible_player
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
# Still require quickconnect validation if provided
if not player:
if not player_name and not hostname:
log_action('warning', f'Player feedback missing required fields. Data: {data}')
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
else:
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404
if not player: if not player:
log_action('warning', f'Player feedback from unknown player: {player_name}') log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
return jsonify({'error': 'Player not found'}), 404 return jsonify({'error': 'Player not found'}), 404
# Validate quickconnect code # Validate quickconnect code if provided (using bcrypt verification)
if player.quickconnect_code != quickconnect_code: if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
log_action('warning', f'Invalid quickconnect in feedback from: {player_name}') log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
return jsonify({'error': 'Invalid quickconnect code'}), 403 return jsonify({'error': 'Invalid quickconnect code'}), 403
# Create feedback record # Create feedback record
@@ -466,7 +493,7 @@ def receive_player_feedback():
db.session.commit() db.session.commit()
log_action('info', f'Feedback received from {player_name}: {status} - {message}') log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
return jsonify({ return jsonify({
'success': True, 'success': True,
@@ -673,6 +700,143 @@ def get_logs():
return jsonify({'error': 'Internal server error'}), 500 return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/player-edit-media', methods=['POST'])
@rate_limit(max_requests=60, window=60)
@verify_player_auth
def receive_edited_media():
"""Receive edited media from player.
Expected multipart/form-data:
- image_file: The edited image file
- metadata: JSON string with metadata
Metadata JSON structure:
{
"time_of_modification": "ISO timestamp",
"original_name": "original_file.jpg",
"new_name": "original_file_v1.jpg",
"version": 1,
"user": "player_user"
}
"""
try:
player = request.player
# Check if file is present
if 'image_file' not in request.files:
return jsonify({'error': 'No image file provided'}), 400
file = request.files['image_file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
# Get metadata
import json
metadata_str = request.form.get('metadata')
if not metadata_str:
return jsonify({'error': 'No metadata provided'}), 400
try:
metadata = json.loads(metadata_str)
except json.JSONDecodeError:
return jsonify({'error': 'Invalid metadata JSON'}), 400
# Validate required metadata fields
required_fields = ['time_of_modification', 'original_name', 'new_name', 'version']
for field in required_fields:
if field not in metadata:
return jsonify({'error': f'Missing required field: {field}'}), 400
# Import required modules
import os
from werkzeug.utils import secure_filename
from app.models.player_edit import PlayerEdit
# Find the original content by filename
original_name = metadata['original_name']
content = Content.query.filter_by(filename=original_name).first()
if not content:
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
return jsonify({'error': f'Original content not found: {original_name}'}), 404
# Create versioned folder structure: edited_media/<content_id>/
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
os.makedirs(edited_media_dir, exist_ok=True)
# Save the edited file with version suffix
version = metadata['version']
new_filename = metadata['new_name']
edited_file_path = os.path.join(edited_media_dir, new_filename)
file.save(edited_file_path)
# Save metadata JSON file
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
metadata_path = os.path.join(edited_media_dir, metadata_filename)
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
# Copy the versioned image to the main uploads folder
import shutil
versioned_upload_path = os.path.join(base_upload_dir, new_filename)
shutil.copy2(edited_file_path, versioned_upload_path)
# Update the content record to reference the new versioned filename
old_filename = content.filename
content.filename = new_filename
# Create edit record
time_of_mod = None
if metadata.get('time_of_modification'):
try:
time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00'))
except:
time_of_mod = datetime.utcnow()
edit_record = PlayerEdit(
player_id=player.id,
content_id=content.id,
original_name=original_name,
new_name=new_filename,
version=version,
user=metadata.get('user'),
time_of_modification=time_of_mod,
metadata_path=metadata_path,
edited_file_path=edited_file_path
)
db.session.add(edit_record)
# Update playlist version to force player refresh
if player.playlist_id:
from app.models.playlist import Playlist
playlist = db.session.get(Playlist, player.playlist_id)
if playlist:
playlist.version += 1
# Clear playlist cache
cache.delete_memoized(get_cached_playlist, player.id)
db.session.commit()
log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})')
return jsonify({
'success': True,
'message': 'Edited media received and processed',
'edit_id': edit_record.id,
'version': version,
'old_filename': old_filename,
'new_filename': new_filename,
'new_playlist_version': playlist.version if player.playlist_id and playlist else None
}), 200
except Exception as e:
db.session.rollback()
log_action('error', f'Error receiving edited media: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@api_bp.errorhandler(404) @api_bp.errorhandler(404)
def api_not_found(error): def api_not_found(error):
"""Handle 404 errors in API.""" """Handle 404 errors in API."""

View File

@@ -3,7 +3,9 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app) flash, jsonify, current_app)
from flask_login import login_required from flask_login import login_required
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from typing import Optional
import os import os
import threading
from app.extensions import db, cache from app.extensions import db, cache
from app.models import Content, Playlist, Player from app.models import Content, Playlist, Player
@@ -11,6 +13,9 @@ from app.models.playlist import playlist_content
from app.utils.logger import log_action from app.utils.logger import log_action
from app.utils.uploads import process_video_file, set_upload_progress from app.utils.uploads import process_video_file, set_upload_progress
# Store for background processing status
_background_tasks = {}
content_bp = Blueprint('content', __name__, url_prefix='/content') content_bp = Blueprint('content', __name__, url_prefix='/content')
@@ -19,15 +24,109 @@ content_bp = Blueprint('content', __name__, url_prefix='/content')
def content_list(): def content_list():
"""Main playlist management page.""" """Main playlist management page."""
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all() playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
media_files = Content.query.order_by(Content.uploaded_at.desc()).all() media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
total_media_count = Content.query.count() # Total count for display
players = Player.query.order_by(Player.name).all() players = Player.query.order_by(Player.name).all()
return render_template('content/content_list_new.html', return render_template('content/content_list_new.html',
playlists=playlists, playlists=playlists,
media_files=media_files, media_files=media_files,
total_media_count=total_media_count,
players=players) players=players)
@content_bp.route('/media-library')
@login_required
def media_library():
"""View all media files in the library."""
from app.models.player_edit import PlayerEdit
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
# Add edit count to each media item
for media in media_files:
media.edit_count = PlayerEdit.query.filter_by(content_id=media.id).count()
# Group by content type
images = [m for m in media_files if m.content_type == 'image']
videos = [m for m in media_files if m.content_type == 'video']
pdfs = [m for m in media_files if m.content_type == 'pdf']
presentations = [m for m in media_files if m.content_type == 'pptx']
others = [m for m in media_files if m.content_type not in ['image', 'video', 'pdf', 'pptx']]
return render_template('content/media_library.html',
media_files=media_files,
images=images,
videos=videos,
pdfs=pdfs,
presentations=presentations,
others=others)
@content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
@login_required
def delete_media(media_id: int):
"""Delete a media file and remove it from all playlists."""
try:
media = Content.query.get_or_404(media_id)
filename = media.filename
# Get all playlists containing this media
affected_playlists = list(media.playlists.all())
# Delete physical file
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], media.filename)
if os.path.exists(file_path):
os.remove(file_path)
log_action('info', f'Deleted physical file: {filename}')
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(media.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
log_action('info', f'Deleted edited media archive for content ID {media.id}')
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
edit_records = PlayerEdit.query.filter_by(content_id=media.id).all()
if edit_records:
for edit in edit_records:
db.session.delete(edit)
log_action('info', f'Deleted {len(edit_records)} edit record(s) for content: {filename}')
# Remove from all playlists (this will cascade properly)
db.session.delete(media)
# Increment version for all affected playlists
for playlist in affected_playlists:
playlist.version += 1
log_action('info', f'Playlist "{playlist.name}" version updated to {playlist.version} (media removed)')
db.session.commit()
# Clear cache for affected playlists
from app.blueprints.players import get_player_playlist
from app.extensions import cache
for playlist in affected_playlists:
for player in playlist.players:
cache.delete_memoized(get_player_playlist, player.id)
if affected_playlists:
flash(f'Deleted "{filename}" and removed from {len(affected_playlists)} playlist(s). Playlist versions updated.', 'success')
else:
flash(f'Deleted "{filename}" successfully.', 'success')
log_action('info', f'Media deleted: {filename} (affected {len(affected_playlists)} playlists)')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting media: {str(e)}')
flash(f'Error deleting media: {str(e)}', 'danger')
return redirect(url_for('content.media_library'))
@content_bp.route('/playlist/create', methods=['POST']) @content_bp.route('/playlist/create', methods=['POST'])
@login_required @login_required
def create_playlist(): def create_playlist():
@@ -194,6 +293,48 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/bulk-remove', methods=['POST'])
@login_required
def bulk_remove_from_playlist(playlist_id: int):
"""Remove multiple content items from playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
from app.models.playlist import playlist_content
# Remove all selected items
stmt = playlist_content.delete().where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id.in_(content_ids))
)
result = db.session.execute(stmt)
# Increment version
playlist.increment_version()
db.session.commit()
cache.clear()
removed_count = result.rowcount if hasattr(result, 'rowcount') else len(content_ids)
log_action('info', f'Bulk removed {removed_count} items from playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': f'Removed {removed_count} item(s) from playlist',
'removed_count': removed_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST']) @content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
@login_required @login_required
def reorder_playlist_content(playlist_id: int): def reorder_playlist_content(playlist_id: int):
@@ -235,6 +376,88 @@ def reorder_playlist_content(playlist_id: int):
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_playlist_content_muted(playlist_id: int, content_id: int):
"""Update content muted setting in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
from app.models.playlist import playlist_content
from sqlalchemy import update
# Update muted in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(muted=muted)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated muted={muted} for "{content.filename}" in playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': 'Audio setting updated',
'muted': muted,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating muted setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/update-edit-enabled/<int:content_id>', methods=['POST'])
@login_required
def update_playlist_content_edit_enabled(playlist_id: int, content_id: int):
"""Update content edit_on_player_enabled setting in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content = Content.query.get_or_404(content_id)
edit_enabled = request.form.get('edit_enabled', 'false').lower() == 'true'
from app.models.playlist import playlist_content
from sqlalchemy import update
# Update edit_on_player_enabled in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(edit_on_player_enabled=edit_enabled)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated edit_on_player_enabled={edit_enabled} for "{content.filename}" in playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': 'Edit setting updated',
'edit_enabled': edit_enabled,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating edit setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/upload-media-page') @content_bp.route('/upload-media-page')
@login_required @login_required
def upload_media_page(): def upload_media_page():
@@ -365,6 +588,182 @@ def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
return False, f"PDF processing error: {str(e)}" return False, f"PDF processing error: {str(e)}"
def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
duration: int, playlist_id: Optional[int], task_id: str, edit_on_player_enabled: bool = False):
"""Process large files (PDF, PPTX, Video) in background thread."""
with app.app_context():
try:
_background_tasks[task_id] = {'status': 'processing', 'message': f'Processing {filename}...'}
upload_folder = app.config['UPLOAD_FOLDER']
processing_success = True
processing_message = ""
detected_type = file_ext
# Process based on file type
if file_ext == 'pdf':
processing_success, processing_message = process_pdf_file(filepath, filename)
if processing_success and "converted to" in processing_message.lower():
base_name = os.path.splitext(filename)[0]
page_pattern = f"{base_name}_page*.png"
import glob
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
if page_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
for page_file in page_files:
page_filename = os.path.basename(page_file)
page_content = Content(
filename=page_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(page_file)
)
db.session.add(page_content)
db.session.flush()
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=page_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
if playlist_id and page_files:
playlist = Playlist.query.get(playlist_id)
if playlist:
playlist.version += 1
db.session.commit()
cache.clear()
if os.path.exists(filepath):
os.remove(filepath)
_background_tasks[task_id] = {
'status': 'complete',
'message': f'PDF converted to {len(page_files)} images successfully!'
}
log_action('info', f'Background: PDF {filename} converted to {len(page_files)} pages')
return
elif file_ext in ['ppt', 'pptx']:
processing_success, processing_message = process_presentation_file(filepath, filename)
if processing_success and "converted to" in processing_message.lower():
base_name = os.path.splitext(filename)[0]
slide_pattern = f"{base_name}_slide_*.png"
import glob
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
if slide_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
for slide_file in slide_files:
slide_filename = os.path.basename(slide_file)
slide_content = Content(
filename=slide_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(slide_file)
)
db.session.add(slide_content)
db.session.flush()
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=slide_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
if playlist_id and slide_files:
playlist = Playlist.query.get(playlist_id)
if playlist:
playlist.version += 1
db.session.commit()
cache.clear()
if os.path.exists(filepath):
os.remove(filepath)
_background_tasks[task_id] = {
'status': 'complete',
'message': f'Presentation converted to {len(slide_files)} slides successfully!'
}
log_action('info', f'Background: PPTX {filename} converted to {len(slide_files)} slides')
return
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
processing_success, processing_message = process_video_file_extended(filepath, filename)
detected_type = 'video'
# If file still exists, add as regular content
if processing_success and os.path.exists(filepath):
content = Content(
filename=filename,
content_type=detected_type,
duration=duration,
file_size=os.path.getsize(filepath)
)
db.session.add(content)
db.session.flush()
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if playlist:
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_position + 1,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
playlist.version += 1
db.session.commit()
cache.clear()
_background_tasks[task_id] = {'status': 'complete', 'message': f'{filename} processed successfully!'}
log_action('info', f'Background: {filename} processed successfully')
else:
_background_tasks[task_id] = {
'status': 'error',
'message': f'Failed to process {filename}: {processing_message}'
}
log_action('error', f'Background: Failed to process {filename}')
except Exception as e:
db.session.rollback()
_background_tasks[task_id] = {'status': 'error', 'message': f'Error: {str(e)}'}
log_action('error', f'Background processing error for {filename}: {str(e)}')
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]: def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PowerPoint presentation files by converting slides to images.""" """Process PowerPoint presentation files by converting slides to images."""
try: try:
@@ -412,17 +811,16 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
temp_ppt = temp_path / filename temp_ppt = temp_path / filename
shutil.copy2(filepath, temp_ppt) shutil.copy2(filepath, temp_ppt)
# Convert presentation to images (PNG format) # Convert presentation to PDF first (for better quality)
# Using LibreOffice headless mode with custom resolution
convert_cmd = [ convert_cmd = [
libreoffice_cmd, libreoffice_cmd,
'--headless', '--headless',
'--convert-to', 'png', '--convert-to', 'pdf',
'--outdir', str(temp_path), '--outdir', str(temp_path),
str(temp_ppt) str(temp_ppt)
] ]
log_action('info', f'Converting presentation to images: {filename}') log_action('info', f'Converting presentation to PDF: {filename}')
try: try:
result = subprocess.run( result = subprocess.run(
@@ -436,8 +834,40 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
log_action('error', f'LibreOffice conversion failed: {result.stderr}') log_action('error', f'LibreOffice conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (conversion failed)" return True, "Presentation accepted without conversion (conversion failed)"
# Find generated PDF file
pdf_files = list(temp_path.glob('*.pdf'))
if not pdf_files:
log_action('warning', f'No PDF generated from presentation: {filename}')
return True, "Presentation accepted without conversion"
pdf_file = pdf_files[0]
log_action('info', f'Converting PDF to images at Full HD resolution: {pdf_file.name}')
# Convert PDF to images using pdftoppm at Full HD resolution (1920x1080)
# Calculate DPI for Full HD output (assuming standard presentation is 10x7.5 inches)
# 1920/10 = 192 DPI for width, use 192 DPI for best quality
pdftoppm_cmd = [
'pdftoppm',
'-png',
'-r', '300', # High DPI for quality
'-scale-to', '1920', # Scale width to 1920px
str(pdf_file),
str(temp_path / 'slide')
]
result = subprocess.run(
pdftoppm_cmd,
capture_output=True,
text=True,
timeout=120
)
if result.returncode != 0:
log_action('error', f'pdftoppm conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (image conversion failed)"
# Find generated PNG files # Find generated PNG files
png_files = sorted(temp_path.glob('*.png')) png_files = sorted(temp_path.glob('slide-*.png'))
if not png_files: if not png_files:
log_action('warning', f'No images generated from presentation: {filename}') log_action('warning', f'No images generated from presentation: {filename}')
@@ -447,7 +877,7 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
upload_folder = current_app.config['UPLOAD_FOLDER'] upload_folder = current_app.config['UPLOAD_FOLDER']
base_name = os.path.splitext(filename)[0] base_name = os.path.splitext(filename)[0]
# Move converted images to upload folder # Move converted images to upload folder and resize to exact Full HD
slide_count = 0 slide_count = 0
for idx, png_file in enumerate(png_files, start=1): for idx, png_file in enumerate(png_files, start=1):
# Create descriptive filename # Create descriptive filename
@@ -456,8 +886,8 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
shutil.move(str(png_file), destination) shutil.move(str(png_file), destination)
# Optimize the image to Full HD (1920x1080) # Resize to exact Full HD dimensions (1920x1080) maintaining aspect ratio
optimize_image_to_fullhd(destination) resize_image_to_fullhd(destination)
slide_count += 1 slide_count += 1
@@ -502,6 +932,64 @@ def create_fullhd_image(img):
return fullhd_img return fullhd_img
def resize_image_to_fullhd(filepath: str) -> bool:
"""Resize image to exactly Full HD (1920x1080) maintaining aspect ratio with centered crop or padding."""
try:
from PIL import Image
img = Image.open(filepath)
target_width = 1920
target_height = 1080
# Calculate aspect ratios
img_aspect = img.width / img.height
target_aspect = target_width / target_height
if abs(img_aspect - target_aspect) < 0.01:
# Aspect ratio is very close, just resize
img_resized = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
elif img_aspect > target_aspect:
# Image is wider than target, fit height and crop/pad width
new_height = target_height
new_width = int(target_height * img_aspect)
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to center if wider
if new_width > target_width:
left = (new_width - target_width) // 2
img_resized = img_resized.crop((left, 0, left + target_width, target_height))
else:
# Pad with white if narrower
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
offset = (target_width - new_width) // 2
result.paste(img_resized, (offset, 0))
img_resized = result
else:
# Image is taller than target, fit width and crop/pad height
new_width = target_width
new_height = int(target_width / img_aspect)
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to center if taller
if new_height > target_height:
top = (new_height - target_height) // 2
img_resized = img_resized.crop((0, top, target_width, top + target_height))
else:
# Pad with white if shorter
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
offset = (target_height - new_height) // 2
result.paste(img_resized, (0, offset))
img_resized = result
# Save optimized image
img_resized.save(filepath, 'PNG', optimize=True, quality=95)
return True
except Exception as e:
log_action('error', f'Image resize error: {str(e)}')
return False
def optimize_image_to_fullhd(filepath: str) -> bool: def optimize_image_to_fullhd(filepath: str) -> bool:
"""Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio.""" """Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio."""
try: try:
@@ -526,6 +1014,7 @@ def upload_media():
content_type = request.form.get('content_type', 'image') content_type = request.form.get('content_type', 'image')
duration = request.form.get('duration', type=int, default=10) duration = request.form.get('duration', type=int, default=10)
playlist_id = request.form.get('playlist_id', type=int) playlist_id = request.form.get('playlist_id', type=int)
edit_on_player_enabled = request.form.get('edit_on_player_enabled', '0') == '1'
if not files or files[0].filename == '': if not files or files[0].filename == '':
flash('No files provided.', 'warning') flash('No files provided.', 'warning')
@@ -535,6 +1024,7 @@ def upload_media():
os.makedirs(upload_folder, exist_ok=True) os.makedirs(upload_folder, exist_ok=True)
uploaded_count = 0 uploaded_count = 0
background_count = 0
processing_errors = [] processing_errors = []
for file in files: for file in files:
@@ -556,6 +1046,26 @@ def upload_media():
# Determine content type from extension # Determine content type from extension
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
# Check if file needs background processing (large files)
needs_background = file_ext in ['pdf', 'ppt', 'pptx', 'mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']
if needs_background:
# Process in background thread
import uuid
task_id = str(uuid.uuid4())
_background_tasks[task_id] = {'status': 'queued', 'message': f'Queued {filename} for processing...'}
thread = threading.Thread(
target=process_file_in_background,
args=(current_app._get_current_object(), filepath, filename, file_ext, duration, playlist_id, task_id, edit_on_player_enabled)
)
thread.daemon = True
thread.start()
background_count += 1
log_action('info', f'Queued {filename} for background processing (task: {task_id})')
continue
# Process file based on type # Process file based on type
processing_success = True processing_success = True
processing_message = "" processing_message = ""
@@ -610,7 +1120,8 @@ def upload_media():
playlist_id=playlist_id, playlist_id=playlist_id,
content_id=page_content.id, content_id=page_content.id,
position=max_position, position=max_position,
duration=duration duration=duration,
edit_on_player_enabled=edit_on_player_enabled
) )
db.session.execute(stmt) db.session.execute(stmt)
@@ -618,7 +1129,7 @@ def upload_media():
# Increment playlist version if pages were added # Increment playlist version if pages were added
if playlist_id and page_files: if playlist_id and page_files:
playlist.version += 1 playlist.increment_version()
# Delete original PDF file # Delete original PDF file
if os.path.exists(filepath): if os.path.exists(filepath):
@@ -669,7 +1180,8 @@ def upload_media():
playlist_id=playlist_id, playlist_id=playlist_id,
content_id=slide_content.id, content_id=slide_content.id,
position=max_position, position=max_position,
duration=duration duration=duration,
edit_on_player_enabled=edit_on_player_enabled
) )
db.session.execute(stmt) db.session.execute(stmt)
@@ -677,7 +1189,7 @@ def upload_media():
# Increment playlist version if slides were added # Increment playlist version if slides were added
if playlist_id and slide_files: if playlist_id and slide_files:
playlist.version += 1 playlist.increment_version()
# Delete original PPTX file # Delete original PPTX file
if os.path.exists(filepath): if os.path.exists(filepath):
@@ -721,19 +1233,20 @@ def upload_media():
playlist_id=playlist_id, playlist_id=playlist_id,
content_id=content.id, content_id=content.id,
position=max_position + 1, position=max_position + 1,
duration=duration duration=duration,
edit_on_player_enabled=edit_on_player_enabled
) )
db.session.execute(stmt) db.session.execute(stmt)
# Increment playlist version # Increment playlist version
playlist.version += 1 playlist.increment_version()
uploaded_count += 1 uploaded_count += 1
db.session.commit() db.session.commit()
cache.clear() cache.clear()
log_action('info', f'Uploaded {uploaded_count} media files') log_action('info', f'Uploaded {uploaded_count} media files, {background_count} files processing in background')
# Show appropriate flash message # Show appropriate flash message
if processing_errors: if processing_errors:
@@ -741,6 +1254,14 @@ def upload_media():
if len(processing_errors) > 3: if len(processing_errors) > 3:
error_summary += f' and {len(processing_errors) - 3} more...' error_summary += f' and {len(processing_errors) - 3} more...'
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning') flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
elif background_count > 0:
bg_msg = f'{background_count} file(s) are being processed in the background (PDF, PPTX, or large videos). '
bg_msg += 'They will appear in the media library/playlist automatically when conversion completes. '
bg_msg += 'This may take a few minutes.'
if uploaded_count > 0:
flash(f'✅ Uploaded {uploaded_count} file(s) immediately. ⏳ {bg_msg}', 'info')
else:
flash(f'{bg_msg}', 'info')
elif playlist_id: elif playlist_id:
playlist = Playlist.query.get(playlist_id) playlist = Playlist.query.get(playlist_id)
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success') flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')

View File

@@ -228,21 +228,48 @@ def manage_player(player_id: int):
try: try:
if action == 'update_credentials': if action == 'update_credentials':
# Update player name, location, orientation # Update player name, location, orientation, and authentication
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip() location = request.form.get('location', '').strip()
orientation = request.form.get('orientation', 'Landscape') orientation = request.form.get('orientation', 'Landscape')
hostname = request.form.get('hostname', '').strip()
password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip()
if not name or len(name) < 3: if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning') flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id)) return redirect(url_for('players.manage_player', player_id=player_id))
if not hostname or len(hostname) < 3:
flash('Hostname must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Check if hostname is taken by another player
if hostname != player.hostname:
existing = Player.query.filter_by(hostname=hostname).first()
if existing:
flash(f'Hostname "{hostname}" is already in use by another player.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
# Update basic info
player.name = name player.name = name
player.hostname = hostname
player.location = location or None player.location = location or None
player.orientation = orientation player.orientation = orientation
# Update password if provided
if password:
player.set_password(password)
log_action('info', f'Password updated for player "{name}"')
# Update quickconnect code if provided
if quickconnect_code:
player.set_quickconnect_code(quickconnect_code)
log_action('info', f'QuickConnect code updated for player "{name}" to: {quickconnect_code}')
db.session.commit() db.session.commit()
log_action('info', f'Player "{name}" credentials updated') log_action('info', f'Player "{name}" (hostname: {hostname}) credentials updated')
flash(f'Player "{name}" updated successfully.', 'success') flash(f'Player "{name}" updated successfully.', 'success')
elif action == 'assign_playlist': elif action == 'assign_playlist':
@@ -288,6 +315,13 @@ def manage_player(player_id: int):
.limit(20)\ .limit(20)\
.all() .all()
# Get edited media history from player
from app.models.player_edit import PlayerEdit
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.limit(20)\
.all()
# Get player status # Get player status
status_info = get_player_status_info(player_id) status_info = get_player_status_info(player_id)
@@ -296,9 +330,41 @@ def manage_player(player_id: int):
playlists=playlists, playlists=playlists,
current_playlist=current_playlist, current_playlist=current_playlist,
recent_logs=recent_logs, recent_logs=recent_logs,
edited_media=edited_media,
status_info=status_info) status_info=status_info)
@players_bp.route('/<int:player_id>/edited-media')
@login_required
def edited_media(player_id: int):
"""Display all edited media files from this player."""
try:
player = Player.query.get_or_404(player_id)
# Get all edited media history from player
from app.models.player_edit import PlayerEdit
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
.order_by(PlayerEdit.created_at.desc())\
.all()
# Get original content files for each edited media
content_files = {}
for edit in edited_media:
if edit.content_id not in content_files:
content = Content.query.get(edit.content_id)
if content:
content_files[edit.content_id] = content
return render_template('players/edited_media.html',
player=player,
edited_media=edited_media,
content_files=content_files)
except Exception as e:
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
flash('Error loading edited media.', 'danger')
return redirect(url_for('players.manage_player', player_id=player_id))
@players_bp.route('/<int:player_id>/fullscreen') @players_bp.route('/<int:player_id>/fullscreen')
def player_fullscreen(player_id: int): def player_fullscreen(player_id: int):
"""Display player fullscreen view (no authentication required for players).""" """Display player fullscreen view (no authentication required for players)."""
@@ -353,6 +419,7 @@ def get_player_playlist(player_id: int) -> List[dict]:
'type': content.content_type, 'type': content.content_type,
'duration': getattr(content, '_playlist_duration', content.duration or 10), 'duration': getattr(content, '_playlist_duration', content.duration or 10),
'position': getattr(content, '_playlist_position', 0), 'position': getattr(content, '_playlist_position', 0),
'muted': getattr(content, '_playlist_muted', True),
'filename': content.filename 'filename': content.filename
}) })

View File

@@ -239,6 +239,49 @@ def update_duration(player_id: int, content_id: int):
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_muted(player_id: int, content_id: int):
"""Update content muted setting in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
# Update muted in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(muted=muted)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Audio setting updated',
'muted': muted,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating muted setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/clear', methods=['POST']) @playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
@login_required @login_required
def clear_playlist(player_id: int): def clear_playlist(player_id: int):

View File

@@ -71,6 +71,7 @@ class ProductionConfig(Config):
DEBUG = False DEBUG = False
TESTING = False TESTING = False
TEMPLATES_AUTO_RELOAD = True # Force template reload
# Database - construct absolute path # Database - construct absolute path
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) _basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
@@ -79,11 +80,8 @@ class ProductionConfig(Config):
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}' f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
) )
# Redis Cache # Cache - use simple cache instead of Redis
CACHE_TYPE = 'redis' CACHE_TYPE = 'simple'
CACHE_REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
CACHE_REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
CACHE_REDIS_DB = 0
CACHE_DEFAULT_TIMEOUT = 300 CACHE_DEFAULT_TIMEOUT = 300
# Security # Security

View File

@@ -6,6 +6,7 @@ from app.models.playlist import Playlist, playlist_content
from app.models.content import Content from app.models.content import Content
from app.models.server_log import ServerLog from app.models.server_log import ServerLog
from app.models.player_feedback import PlayerFeedback from app.models.player_feedback import PlayerFeedback
from app.models.player_edit import PlayerEdit
__all__ = [ __all__ = [
'User', 'User',
@@ -15,6 +16,7 @@ __all__ = [
'Content', 'Content',
'ServerLog', 'ServerLog',
'PlayerFeedback', 'PlayerFeedback',
'PlayerEdit',
'group_content', 'group_content',
'playlist_content', 'playlist_content',
] ]

60
app/models/player_edit.py Normal file
View File

@@ -0,0 +1,60 @@
"""Player edit model for tracking media edited on players."""
from datetime import datetime
from typing import Optional
from app.extensions import db
class PlayerEdit(db.Model):
"""Player edit model for tracking media files edited on player devices.
Attributes:
id: Primary key
player_id: Foreign key to player
content_id: Foreign key to content that was edited
original_name: Original filename
new_name: New filename after editing
version: Edit version number (v1, v2, etc.)
user: User who made the edit (from player)
time_of_modification: When the edit was made
metadata_path: Path to the metadata JSON file
edited_file_path: Path to the edited file
created_at: Record creation timestamp
"""
__tablename__ = 'player_edit'
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), nullable=False, index=True)
content_id = db.Column(db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), nullable=False, index=True)
original_name = db.Column(db.String(255), nullable=False)
new_name = db.Column(db.String(255), nullable=False)
version = db.Column(db.Integer, default=1, nullable=False)
user = db.Column(db.String(255), nullable=True)
time_of_modification = db.Column(db.DateTime, nullable=True)
metadata_path = db.Column(db.String(512), nullable=True)
edited_file_path = db.Column(db.String(512), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
player = db.relationship('Player', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
content = db.relationship('Content', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
def __repr__(self) -> str:
"""String representation of PlayerEdit."""
return f'<PlayerEdit {self.original_name} v{self.version} by {self.user or "unknown"}>'
def to_dict(self) -> dict:
"""Convert to dictionary for API responses."""
return {
'id': self.id,
'player_id': self.player_id,
'player_name': self.player.name if self.player else None,
'content_id': self.content_id,
'original_name': self.original_name,
'new_name': self.new_name,
'version': self.version,
'user': self.user,
'time_of_modification': self.time_of_modification.isoformat() if self.time_of_modification else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'edited_file_path': self.edited_file_path
}

View File

@@ -10,7 +10,9 @@ playlist_content = db.Table('playlist_content',
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True), db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True),
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True), db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
db.Column('position', db.Integer, default=0), db.Column('position', db.Integer, default=0),
db.Column('duration', db.Integer, default=10) db.Column('duration', db.Integer, default=10),
db.Column('muted', db.Boolean, default=True),
db.Column('edit_on_player_enabled', db.Boolean, default=False)
) )
@@ -76,7 +78,9 @@ class Playlist(db.Model):
from sqlalchemy import select from sqlalchemy import select
stmt = select(playlist_content.c.content_id, stmt = select(playlist_content.c.content_id,
playlist_content.c.position, playlist_content.c.position,
playlist_content.c.duration).where( playlist_content.c.duration,
playlist_content.c.muted,
playlist_content.c.edit_on_player_enabled).where(
playlist_content.c.playlist_id == self.id playlist_content.c.playlist_id == self.id
).order_by(playlist_content.c.position) ).order_by(playlist_content.c.position)
@@ -88,6 +92,8 @@ class Playlist(db.Model):
if content: if content:
content._playlist_position = row.position content._playlist_position = row.position
content._playlist_duration = row.duration content._playlist_duration = row.duration
content._playlist_muted = row.muted if len(row) > 3 else True
content._playlist_edit_on_player_enabled = row.edit_on_player_enabled if len(row) > 4 else False
ordered_content.append(content) ordered_content.append(content)
return ordered_content return ordered_content

View File

@@ -72,7 +72,7 @@
<td style="padding: 10px;">📷 {{ img.filename }}</td> <td style="padding: 10px;">📷 {{ img.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td> <td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ img.duration }}s</td> <td style="padding: 10px;">{{ img.duration }}s</td>
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td> <td style="padding: 10px;">{{ img.uploaded_at | localtime if img.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;"> <td style="padding: 10px;">
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');"> <form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button> <button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
@@ -122,7 +122,7 @@
<td style="padding: 10px;">🎥 {{ video.filename }}</td> <td style="padding: 10px;">🎥 {{ video.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td> <td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ video.duration }}s</td> <td style="padding: 10px;">{{ video.duration }}s</td>
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td> <td style="padding: 10px;">{{ video.uploaded_at | localtime if video.uploaded_at else 'N/A' }}</td>
<td style="padding: 10px;"> <td style="padding: 10px;">
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');"> <form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button> <button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
@@ -162,7 +162,7 @@
<td style="padding: 10px;">📄 {{ pdf.filename }}</td> <td style="padding: 10px;">📄 {{ pdf.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td> <td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ pdf.duration }}s</td> <td style="padding: 10px;">{{ pdf.duration }}s</td>
<td style="padding: 10px;">{{ pdf.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}</td> <td style="padding: 10px;">{{ pdf.uploaded_at | localtime if pdf.uploaded_at else 'N/A' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -37,8 +37,8 @@
{{ user.role|capitalize }} {{ user.role|capitalize }}
</span> </span>
</td> </td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}</td> <td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else 'Never' }}</td> <td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
<td class="actions"> <td class="actions">
{% if user.id != current_user.id %} {% if user.id != current_user.id %}
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')"> <button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">

View File

@@ -222,10 +222,6 @@
<button type="submit" class="btn-login">Login</button> <button type="submit" class="btn-login">Login</button>
</form> </form>
<div class="register-link">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -69,6 +69,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 1rem;
} }
header h1 { header h1 {
font-size: 1.5rem; font-size: 1.5rem;
@@ -80,6 +82,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
} }
nav a { nav a {
color: white; color: white;
@@ -102,6 +105,69 @@
filter: brightness(0) invert(1); filter: brightness(0) invert(1);
} }
/* Mobile Responsive */
@media (max-width: 768px) {
header .container {
flex-direction: column;
align-items: stretch;
padding: 1rem;
}
header h1 {
font-size: 1.25rem;
justify-content: center;
text-align: center;
}
nav {
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
nav a {
justify-content: center;
width: 100%;
padding: 0.75rem 1rem;
}
.dark-mode-toggle {
margin-left: 0;
width: 100%;
padding: 0.75rem;
}
.container {
padding: 15px;
}
.card {
padding: 1rem;
margin-bottom: 1rem;
}
.card-header {
padding: 15px;
margin: -1rem -1rem 1rem -1rem;
}
}
@media (max-width: 480px) {
header h1 {
font-size: 1.1rem;
}
nav a {
font-size: 0.9rem;
padding: 0.6rem 0.8rem;
}
nav a img {
width: 16px;
height: 16px;
}
}
.dark-mode-toggle { .dark-mode-toggle {
background: rgba(255,255,255,0.2); background: rgba(255,255,255,0.2);
border: 2px solid rgba(255,255,255,0.3); border: 2px solid rgba(255,255,255,0.3);

View File

@@ -75,7 +75,7 @@
{% endif %} {% endif %}
</td> </td>
<td style="padding: 12px;"> <td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</small> <small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
</td> </td>
<td style="padding: 12px;"> <td style="padding: 12px;">
{% if item.player_count > 0 %} {% if item.player_count > 0 %}

View File

@@ -249,6 +249,12 @@
background: #1a202c !important; background: #1a202c !important;
color: #e2e8f0; color: #e2e8f0;
} }
/* Dark mode for upload section */
body.dark-mode .card > div[style*="background: #f8f9fa"] {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
</style> </style>
<div class="container" style="max-width: 1400px;"> <div class="container" style="max-width: 1400px;">
@@ -258,18 +264,17 @@
</h1> </h1>
<div class="main-grid"> <div class="main-grid">
<!-- Create/Manage Playlists Card --> <!-- Create Playlist Card -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;"> <h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);"> <img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Playlists Create New Playlist
</h2> </h2>
</div> </div>
<!-- Create New Playlist Form --> <!-- Create New Playlist Form -->
<form method="POST" action="{{ url_for('content.create_playlist') }}" style="margin-bottom: 25px;"> <form method="POST" action="{{ url_for('content.create_playlist') }}">
<h3 style="margin-bottom: 15px;">Create New Playlist</h3>
<div class="form-group"> <div class="form-group">
<label for="playlist_name">Playlist Name *</label> <label for="playlist_name">Playlist Name *</label>
<input type="text" name="name" id="playlist_name" class="form-control" required <input type="text" name="name" id="playlist_name" class="form-control" required
@@ -294,47 +299,6 @@
Create Playlist Create Playlist
</button> </button>
</form> </form>
<hr style="margin: 25px 0;">
<!-- Existing Playlists -->
<h3 style="margin-bottom: 15px;">Existing Playlists</h3>
<div class="playlist-list">
{% if playlists %}
{% for playlist in playlists %}
<div class="playlist-item">
<div class="playlist-info">
<h3>{{ playlist.name }}</h3>
<div class="playlist-stats">
📊 {{ playlist.content_count }} items |
👥 {{ playlist.player_count }} players |
🔄 v{{ playlist.version }}
</div>
</div>
<div class="playlist-actions">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
class="btn btn-primary btn-sm">
✏️ Manage
</a>
<form method="POST"
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
style="display: inline;"
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
<p>No playlists yet. Create your first playlist above!</p>
</div>
{% endif %}
</div>
</div> </div>
<!-- Upload Media Card --> <!-- Upload Media Card -->
@@ -356,9 +320,10 @@
<!-- Media Library with Thumbnails --> <!-- Media Library with Thumbnails -->
<h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;"> <h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
<span>📚 Available Media ({{ media_files|length }})</span> <span>📚 Last 3 Added Media</span>
<small style="color: #6c757d; font-size: 0.85rem;">Total: {{ total_media_count }}</small>
</h3> </h3>
<div class="media-library" style="max-height: 500px; overflow-y: auto;"> <div class="media-library" style="max-height: 350px; overflow-y: auto;">
{% if media_files %} {% if media_files %}
{% for media in media_files %} {% for media in media_files %}
<div class="media-item" title="{{ media.filename }}"> <div class="media-item" title="{{ media.filename }}">
@@ -393,6 +358,63 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- View All Media Button -->
{% if total_media_count > 3 %}
<div style="text-align: center; padding: 15px; border-top: 1px solid #dee2e6; margin-top: 10px;">
<a href="{{ url_for('content.media_library') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<span>📚</span>
View All Media ({{ total_media_count }} files)
</a>
</div>
{% endif %}
</div>
</div>
<!-- Existing Playlists Card -->
<div class="card full-width">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Existing Playlists
</h2>
</div>
<div class="playlist-list">
{% if playlists %}
{% for playlist in playlists %}
<div class="playlist-item">
<div class="playlist-info">
<h3>{{ playlist.name }}</h3>
<div class="playlist-stats">
📊 {{ playlist.content_count }} items |
👥 {{ playlist.player_count }} players |
🔄 v{{ playlist.version }}
</div>
</div>
<div class="playlist-actions">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
class="btn btn-primary btn-sm">
✏️ Manage
</a>
<form method="POST"
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
style="display: inline;"
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
<p>No playlists yet. Create your first playlist above!</p>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -88,6 +88,72 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
/* 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);
}
/* Dark mode support */
body.dark-mode .playlist-table th {
background: #1a202c;
color: #cbd5e0;
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table td {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .draggable-row:hover {
background: #1a202c;
}
body.dark-mode .drag-handle {
color: #718096;
}
body.dark-mode .content-item {
background: #1a202c;
color: #e2e8f0;
}
body.dark-mode .available-content {
color: #e2e8f0;
}
</style> </style>
<div class="container" style="max-width: 1400px;"> <div class="container" style="max-width: 1400px;">
@@ -125,23 +191,36 @@
<div class="content-grid"> <div class="content-grid">
<div class="card"> <div class="card">
<h2 style="margin-bottom: 20px;">📋 Playlist Content (Drag to Reorder)</h2> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2>
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
🗑️ Delete Selected (<span id="selected-count">0</span>)
</button>
</div>
{% if playlist_content %} {% if playlist_content %}
<table class="playlist-table" id="playlist-table"> <table class="playlist-table" id="playlist-table">
<thead> <thead>
<tr> <tr>
<th style="width: 40px;">
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" title="Select all">
</th>
<th style="width: 40px;"></th> <th style="width: 40px;"></th>
<th style="width: 50px;">#</th> <th style="width: 50px;">#</th>
<th>Filename</th> <th>Filename</th>
<th style="width: 100px;">Type</th> <th style="width: 100px;">Type</th>
<th style="width: 100px;">Duration</th> <th style="width: 100px;">Duration</th>
<th style="width: 80px;">Audio</th>
<th style="width: 80px;">Edit</th>
<th style="width: 100px;">Actions</th> <th style="width: 100px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="playlist-tbody"> <tbody id="playlist-tbody">
{% for content in playlist_content %} {% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}"> <tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
<td>
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">
</td>
<td><span class="drag-handle">⋮⋮</span></td> <td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td>{{ content.filename }}</td> <td>{{ content.filename }}</td>
@@ -152,6 +231,40 @@
{% else %}📁 Other{% endif %} {% else %}📁 Other{% endif %}
</td> </td>
<td>{{ content._playlist_duration or content.duration }}s</td> <td>{{ content._playlist_duration or content.duration }}s</td>
<td>
{% if content.content_type == 'video' %}
<label class="audio-toggle">
<input type="checkbox"
class="audio-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if not content._playlist_muted else '' }}
onchange="toggleAudio({{ content.id }}, this.checked)">
<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>
{% if content.content_type in ['image', 'pdf'] %}
<label class="audio-toggle">
<input type="checkbox"
class="edit-checkbox"
data-content-id="{{ content.id }}"
{{ 'checked' if content._playlist_edit_on_player_enabled else '' }}
onchange="toggleEdit({{ content.id }}, this.checked)">
<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> <td>
<form method="POST" <form method="POST"
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}" action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
@@ -299,6 +412,145 @@ function saveOrder() {
console.error('Error:', error); console.error('Error:', error);
}); });
} }
function toggleAudio(contentId, enabled) {
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/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;
});
}
function toggleEdit(contentId, enabled) {
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
const formData = new FormData();
formData.append('edit_enabled', enabled ? 'true' : 'false');
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Edit setting updated:', enabled ? 'Enabled' : 'Disabled');
} else {
alert('Error updating edit setting: ' + data.message);
// Revert checkbox on error
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating edit setting');
// Revert checkbox on error
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
if (checkbox) checkbox.checked = !enabled;
});
}
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.content-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBulkDeleteButton();
}
function updateBulkDeleteButton() {
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
const count = checkboxes.length;
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
const selectedCount = document.getElementById('selected-count');
if (count > 0) {
bulkDeleteBtn.style.display = 'block';
selectedCount.textContent = count;
} else {
bulkDeleteBtn.style.display = 'none';
}
// Update select-all checkbox state
const allCheckboxes = document.querySelectorAll('.content-checkbox');
const selectAllCheckbox = document.getElementById('select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = allCheckboxes.length > 0 && count === allCheckboxes.length;
selectAllCheckbox.indeterminate = count > 0 && count < allCheckboxes.length;
}
}
function bulkDeleteSelected() {
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
const contentIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.contentId));
if (contentIds.length === 0) {
alert('No items selected');
return;
}
const confirmMsg = `Are you sure you want to remove ${contentIds.length} item(s) from this playlist?`;
if (!confirm(confirmMsg)) {
return;
}
// Show loading state
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
const originalText = bulkDeleteBtn.innerHTML;
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.innerHTML = '⏳ Removing...';
fetch('{{ url_for("content.bulk_remove_from_playlist", playlist_id=playlist.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content_ids: contentIds })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload page to show updated playlist
window.location.reload();
} else {
alert('Error removing items: ' + data.message);
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.innerHTML = originalText;
}
})
.catch(error => {
console.error('Error:', error);
alert('Error removing items from playlist');
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.innerHTML = originalText;
});
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,806 @@
{% extends "base.html" %}
{% block title %}Media Library - DigiServer v2{% endblock %}
{% block content %}
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px;
margin-top: 20px;
}
.media-card {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 10px;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.media-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
body.dark-mode .media-card {
background: #1a202c;
border-color: #4a5568;
}
.media-thumbnail {
width: 100%;
height: 150px;
border-radius: 6px;
overflow: hidden;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
body.dark-mode .media-thumbnail {
background: #2d3748;
}
.media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-icon {
font-size: 64px;
}
.media-info {
font-size: 12px;
color: #6c757d;
}
body.dark-mode .media-info {
color: #a0aec0;
}
.media-filename {
font-weight: 500;
margin-bottom: 5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
body.dark-mode .media-filename {
color: #e2e8f0;
}
.delete-btn {
position: absolute;
top: 5px;
right: 5px;
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
opacity: 0;
transition: opacity 0.3s ease;
}
.media-card:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: #a02834;
}
.playlist-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
margin-top: 5px;
}
.playlist-badge.in-use {
background: #ffc107;
color: #000;
}
.playlist-badge.unused {
background: #28a745;
color: white;
}
body.dark-mode .playlist-badge.in-use {
background: #856404;
color: #ffc107;
}
body.dark-mode .upload-section {
background: #2d3748 !important;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-badge.unused {
background: #1a4d2e;
color: #86efac;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.type-badge.image { background: #d4edda; color: #155724; }
.type-badge.video { background: #cce5ff; color: #004085; }
.type-badge.pdf { background: #fff3cd; color: #856404; }
.type-badge.pptx { background: #f8d7da; color: #721c24; }
body.dark-mode .type-badge.image { background: #1a4d2e; color: #86efac; }
body.dark-mode .type-badge.video { background: #1e3a5f; color: #93c5fd; }
body.dark-mode .type-badge.pdf { background: #4a3800; color: #fbbf24; }
body.dark-mode .type-badge.pptx { background: #4a1a1a; color: #fca5a5; }
.stats-box {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
text-align: center;
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #7c3aed;
}
body.dark-mode .stat-value {
color: #a78bfa;
}
.stat-label {
font-size: 14px;
color: #6c757d;
margin-top: 5px;
}
body.dark-mode .stat-label {
color: #a0aec0;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin: 30px 0 15px 0;
padding-bottom: 10px;
border-bottom: 2px solid #7c3aed;
}
body.dark-mode .section-header {
border-bottom-color: #a78bfa;
}
.section-header h2 {
margin: 0;
font-size: 24px;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
</style>
<div style="margin-bottom: 2rem;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
📚 Media Library
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<!-- Statistics -->
<div class="stats-box">
<div class="stat-item">
<div class="stat-value">{{ media_files|length }}</div>
<div class="stat-label">Total Files</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ images|length }}</div>
<div class="stat-label">Images</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ videos|length }}</div>
<div class="stat-label">Videos</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ pdfs|length }}</div>
<div class="stat-label">PDFs</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ presentations|length }}</div>
<div class="stat-label">Presentations</div>
</div>
</div>
<!-- Upload Button -->
<div class="upload-section" style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 30px;">
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload New Media
</a>
</div>
<!-- Images Section -->
{% if images %}
<div class="section-header">
<span style="font-size: 32px;">📷</span>
<h2>Images ({{ images|length }})</h2>
</div>
<div class="media-grid">
{% for media in images %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
alt="{{ media.filename }}"
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge image">Image</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Videos Section -->
{% if videos %}
<div class="section-header">
<span style="font-size: 32px;">🎥</span>
<h2>Videos ({{ videos|length }})</h2>
</div>
<div class="media-grid">
{% for media in videos %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">🎥</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge video">Video</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- PDFs Section -->
{% if pdfs %}
<div class="section-header">
<span style="font-size: 32px;">📄</span>
<h2>PDFs ({{ pdfs|length }})</h2>
</div>
<div class="media-grid">
{% for media in pdfs %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📄</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pdf">PDF</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Presentations Section -->
{% if presentations %}
<div class="section-header">
<span style="font-size: 32px;">📊</span>
<h2>Presentations ({{ presentations|length }})</h2>
</div>
<div class="media-grid">
{% for media in presentations %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📊</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge pptx">PPTX</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Others Section -->
{% if others %}
<div class="section-header">
<span style="font-size: 32px;">📁</span>
<h2>Other Files ({{ others|length }})</h2>
</div>
<div class="media-grid">
{% for media in others %}
<div class="media-card">
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
<div class="media-thumbnail">
<span class="media-icon">📁</span>
</div>
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
<div class="media-info">
<span class="type-badge">{{ media.content_type }}</span>
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
{% if media.playlists.count() > 0 %}
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
{% else %}
<div class="playlist-badge unused">✓ Not in use</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if not media_files %}
<div style="text-align: center; padding: 80px 20px;">
<div style="font-size: 96px; margin-bottom: 20px;">📭</div>
<h2 style="color: #6c757d;">No Media Files Yet</h2>
<p style="color: #999; margin-bottom: 30px;">Start by uploading your first media file!</p>
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Media
</a>
</div>
{% endif %}
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="delete-modal">
<div class="delete-modal-content">
<div class="delete-modal-header">
<span class="delete-icon">⚠️</span>
<h2>Confirm Delete</h2>
</div>
<div class="delete-modal-body">
<p class="delete-question">
Are you sure you want to delete <strong id="deleteFilename" class="delete-filename"></strong>?
</p>
<div id="playlistWarning" class="warning-box warning-playlist">
<div class="warning-header">
<span>⚠️</span>
<strong>Playlist Warning</strong>
</div>
<p>This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
</div>
<div id="editWarning" class="warning-box warning-edit">
<div class="warning-header">
<span>✏️</span>
<strong>Edited Versions</strong>
</div>
<p>This file has <strong id="editCount"></strong> edited version(s) from player devices. All edited versions and their metadata will also be permanently deleted.</p>
</div>
<div class="delete-final-warning">
<strong>⚠️ This action cannot be undone!</strong>
</div>
</div>
<div class="delete-modal-footer">
<button onclick="closeDeleteModal()" class="btn btn-cancel">
Cancel
</button>
<form id="deleteForm" method="POST">
<button type="submit" class="btn btn-delete">
Yes, Delete File
</button>
</form>
</div>
</div>
</div>
<style>
/* Delete Modal - Light Mode Styles */
.delete-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.delete-modal-content {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
margin: 8% auto;
padding: 0;
border-radius: 16px;
width: 90%;
max-width: 550px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideDown 0.3s ease-out;
border: 1px solid rgba(0, 0, 0, 0.1);
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.delete-modal-header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 24px 30px;
border-radius: 16px 16px 0 0;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
}
.delete-icon {
font-size: 2rem;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.delete-modal-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.delete-modal-body {
padding: 30px;
}
.delete-question {
font-size: 1.1rem;
margin: 0 0 20px 0;
color: #2d3748;
line-height: 1.6;
}
.delete-filename {
color: #dc3545;
word-break: break-word;
}
.warning-box {
display: none;
padding: 16px;
margin: 16px 0;
border-radius: 10px;
border-left: 4px solid;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s ease;
}
.warning-box:hover {
transform: translateX(4px);
}
.warning-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.warning-header span {
font-size: 1.2rem;
}
.warning-header strong {
font-size: 1rem;
font-weight: 600;
}
.warning-box p {
margin: 0;
line-height: 1.5;
font-size: 0.95rem;
}
.warning-playlist {
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
border-color: #ffc107;
}
.warning-playlist .warning-header,
.warning-playlist p {
color: #856404;
}
.warning-edit {
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
border-color: #7c3aed;
}
.warning-edit .warning-header,
.warning-edit p {
color: #5b21b6;
}
.delete-final-warning {
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
border: 2px solid #dc3545;
border-radius: 8px;
padding: 12px 16px;
margin: 20px 0 0 0;
text-align: center;
}
.delete-final-warning strong {
color: #dc3545;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.delete-modal-footer {
padding: 20px 30px;
display: flex;
gap: 12px;
justify-content: flex-end;
background: rgba(0, 0, 0, 0.02);
border-radius: 0 0 16px 16px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.delete-modal-footer form {
margin: 0;
}
.btn-cancel {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.btn-cancel:hover {
background: linear-gradient(135deg, #5a6268 0%, #4e555b 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transform: translateY(-2px);
}
.btn-delete {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
padding: 12px 28px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(220, 53, 69, 0.3);
}
.btn-delete:hover {
background: linear-gradient(135deg, #c82333 0%, #bd2130 100%);
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.5);
transform: translateY(-2px);
}
/* Delete Modal - Dark Mode Styles */
body.dark-mode .delete-modal {
background-color: rgba(0, 0, 0, 0.8);
}
body.dark-mode .delete-modal-content {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border: 1px solid #4a5568;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
body.dark-mode .delete-modal-header {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.4);
}
body.dark-mode .delete-question {
color: #e2e8f0;
}
body.dark-mode .delete-filename {
color: #f87171;
}
body.dark-mode .warning-playlist {
background: linear-gradient(135deg, #422006 0%, #713f12 100%);
border-color: #fbbf24;
}
body.dark-mode .warning-playlist .warning-header,
body.dark-mode .warning-playlist p {
color: #fde68a;
}
body.dark-mode .warning-edit {
background: linear-gradient(135deg, #3b0764 0%, #581c87 100%);
border-color: #a78bfa;
}
body.dark-mode .warning-edit .warning-header,
body.dark-mode .warning-edit p {
color: #ddd6fe;
}
body.dark-mode .delete-final-warning {
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%);
border-color: #f87171;
}
body.dark-mode .delete-final-warning strong {
color: #fca5a5;
}
body.dark-mode .delete-modal-footer {
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid #4a5568;
}
body.dark-mode .btn-cancel {
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
body.dark-mode .btn-cancel:hover {
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
body.dark-mode .btn-delete {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
box-shadow: 0 2px 6px rgba(185, 28, 28, 0.4);
}
body.dark-mode .btn-delete:hover {
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.6);
}
</style>
<script>
let deleteMediaId = null;
function confirmDelete(mediaId, filename, playlistCount, editCount) {
deleteMediaId = mediaId;
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
// Show playlist warning if file is in use
if (playlistCount > 0) {
document.getElementById('playlistWarning').style.display = 'block';
document.getElementById('playlistCount').textContent = playlistCount;
} else {
document.getElementById('playlistWarning').style.display = 'none';
}
// Show edit versions warning if file has been edited
if (editCount > 0) {
document.getElementById('editWarning').style.display = 'block';
document.getElementById('editCount').textContent = editCount;
} else {
document.getElementById('editWarning').style.display = 'none';
}
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
deleteMediaId = null;
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
// Close modal with ESC key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeDeleteModal();
}
});
</script>
{% endblock %}

View File

@@ -296,6 +296,17 @@
</small> </small>
</div> </div>
<div class="form-group">
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span>Allow editing on player (PDF, Images, PPTX)</span>
</label>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
✏️ Enable local editing of this media on the player device
</small>
</div>
<!-- Upload Button --> <!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;"> <button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);"> <img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">

View File

@@ -123,7 +123,7 @@ body.dark-mode .log-item {
[{{ log.level.upper() }}] [{{ log.level.upper() }}]
</span> </span>
{{ log.message }} {{ log.message }}
<small class="secondary-text" style="float: right;">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small> <small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -0,0 +1,525 @@
{% extends "base.html" %}
{% block title %}Edited Media - {{ player.name }}{% endblock %}
{% block content %}
<style>
.expandable-card {
width: 100%;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border: 2px solid #cbd5e1;
border-radius: 12px;
margin-bottom: 1.5rem;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
}
.expandable-card:hover {
border-color: #7c3aed;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
}
.expandable-card.expanded {
border-color: #7c3aed;
cursor: default;
}
.card-header {
padding: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.3s ease;
}
.expandable-card:not(.expanded) .card-header:hover {
background: rgba(124, 58, 237, 0.05);
}
.card-header-title {
display: flex;
align-items: center;
gap: 1rem;
font-size: 1.2rem;
font-weight: 600;
color: #1a202c;
}
.card-header-icon {
font-size: 1.5rem;
transition: transform 0.3s ease;
}
.expandable-card.expanded .card-header-icon {
transform: rotate(90deg);
}
.card-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease;
}
.expandable-card.expanded .card-content {
max-height: 800px;
}
.card-body {
padding: 0 1.5rem 1.5rem 1.5rem;
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
}
.preview-area {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.preview-container {
width: 100%;
aspect-ratio: 16/9;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.preview-container img {
width: 100%;
height: 100%;
object-fit: contain;
}
.preview-info {
background: #f8f9fa;
border-radius: 8px;
padding: 1rem;
font-size: 0.9rem;
}
.preview-info-item {
padding: 0.5rem 0;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: start;
}
.preview-info-item:last-child {
border-bottom: none;
}
.preview-info-label {
font-weight: 600;
color: #64748b;
min-width: 80px;
}
.preview-info-value {
color: #1a202c;
text-align: right;
word-break: break-word;
flex: 1;
}
.versions-area {
display: flex;
flex-direction: column;
}
.versions-title {
font-size: 1.1rem;
font-weight: 600;
color: #7c3aed;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.versions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
max-height: 600px;
overflow-y: auto;
padding-right: 0.5rem;
}
.version-item {
background: white;
border: 2px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
}
.version-item:hover {
border-color: #7c3aed;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
}
.version-item.active {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
.version-thumbnail {
width: 100%;
aspect-ratio: 16/9;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-label {
padding: 0.75rem;
text-align: center;
font-weight: 600;
color: #1a202c;
background: #f8f9fa;
}
.version-item.active .version-label {
background: #7c3aed;
color: white;
}
.version-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
}
.badge-latest {
background: #7c3aed;
color: white;
}
.badge-original {
background: #10b981;
color: white;
}
.download-btn {
width: 100%;
padding: 0.75rem;
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
}
.download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.4);
}
/* Dark mode styles */
body.dark-mode .expandable-card {
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
border-color: #4a5568;
}
body.dark-mode .expandable-card:hover,
body.dark-mode .expandable-card.expanded {
border-color: #7c3aed;
}
body.dark-mode .card-header-title {
color: #e2e8f0;
}
body.dark-mode .expandable-card:not(.expanded) .card-header:hover {
background: rgba(124, 58, 237, 0.15);
}
body.dark-mode .preview-info {
background: #1a202c;
}
body.dark-mode .preview-info-item {
border-bottom-color: #4a5568;
}
body.dark-mode .preview-info-label {
color: #94a3b8;
}
body.dark-mode .preview-info-value {
color: #e2e8f0;
}
body.dark-mode .versions-title {
color: #a78bfa;
}
body.dark-mode .version-item {
background: #2d3748;
border-color: #4a5568;
}
body.dark-mode .version-item:hover,
body.dark-mode .version-item.active {
border-color: #7c3aed;
}
body.dark-mode .version-label {
background: #1a202c;
color: #e2e8f0;
}
body.dark-mode .version-item.active .version-label {
background: #7c3aed;
color: white;
}
</style>
<div style="margin-bottom: 2rem;">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn"
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
← Back to Player
</a>
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 32px; height: 32px;">
Edited Media - {{ player.name }}
</h1>
</div>
<p style="color: #6c757d; font-size: 1rem;">Complete history of media files edited on this player</p>
</div>
{% if edited_media %}
{% set edited_by_content = {} %}
{% for edit in edited_media %}
{% if edit.content_id not in edited_by_content %}
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %}
{% endif %}
{% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %}
{% endfor %}
<!-- Expandable Cards -->
{% for content_id, data in edited_by_content.items() %}
{% set original_content = content_files.get(content_id) %}
<div class="expandable-card" id="card-{{ content_id }}" onclick="toggleCard({{ content_id }})">
<div class="card-header">
<div class="card-header-title">
<span class="card-header-icon"></span>
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
</span>
</div>
</div>
<div class="card-content">
<div class="card-body">
<!-- Preview Area (Left Column) -->
<div class="preview-area">
<div class="preview-container" id="preview-{{ content_id }}">
{% set latest = data.versions|sort(attribute='version', reverse=True)|first %}
{% if latest.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
alt="{{ latest.new_name }}"
id="preview-img-{{ content_id }}">
{% else %}
<div style="color: white; text-align: center;">
<div style="font-size: 3rem; margin-bottom: 0.5rem;">📄</div>
<div>No preview available</div>
</div>
{% endif %}
</div>
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
download
class="download-btn"
id="download-btn-{{ content_id }}">
💾 Download File
</a>
<div class="preview-info" id="info-{{ content_id }}">
<div class="preview-info-item">
<span class="preview-info-label">📄 Filename:</span>
<span class="preview-info-value" id="info-filename-{{ content_id }}">{{ latest.new_name }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">📦 Version:</span>
<span class="preview-info-value" id="info-version-{{ content_id }}">v{{ latest.version }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">👤 Edited by:</span>
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ latest.user or 'Unknown' }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">🕒 Modified:</span>
<span class="preview-info-value" id="info-date-{{ content_id }}">{{ latest.time_of_modification | localtime('%Y-%m-%d %H:%M') if latest.time_of_modification else 'N/A' }}</span>
</div>
<div class="preview-info-item">
<span class="preview-info-label">📅 Uploaded:</span>
<span class="preview-info-value" id="info-created-{{ content_id }}">{{ latest.created_at | localtime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
</div>
<!-- Versions Area (Right Column) -->
<div class="versions-area">
<div class="versions-title">
📚 All Versions
</div>
<div class="versions-grid">
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
<div class="version-item {% if loop.first %}active{% endif %}"
id="version-{{ content_id }}-{{ edit.version }}"
onclick="event.stopPropagation(); selectVersion({{ content_id }}, {{ edit.version }}, '{{ edit.new_name }}', '{{ edit.user or 'Unknown' }}', '{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') if edit.time_of_modification else 'N/A' }}', '{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}')">
<div class="version-thumbnail">
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
alt="Version {{ edit.version }}">
{% else %}
<div style="color: white; font-size: 2rem;">📄</div>
{% endif %}
</div>
<div class="version-label">
v{{ edit.version }}
{% if loop.first %}
<span class="version-badge badge-latest">Latest</span>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Original File -->
{% if original_content %}
<div class="version-item"
id="version-{{ content_id }}-original"
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
<div class="version-thumbnail">
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
alt="Original">
{% else %}
<div style="color: white; font-size: 2rem;">📄</div>
{% endif %}
</div>
<div class="version-label">
Original
<span class="version-badge badge-original">Source</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- Summary Statistics -->
<div class="card" style="margin-top: 2rem; background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: white; border-radius: 12px; overflow: hidden;">
<div style="padding: 1.5rem; display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; gap: 2rem;">
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_by_content|length }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Total Files Edited</div>
</div>
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_media|length }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Total Versions</div>
</div>
<div style="text-align: center;">
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ ((edited_media|length / edited_by_content|length) | round(1)) if edited_by_content else 0 }}</div>
<div style="font-size: 0.9rem; opacity: 0.9;">Avg Versions per File</div>
</div>
</div>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 4rem 2rem;">
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">📭</div>
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Edited Media Yet</h2>
<p style="color: #6c757d; font-size: 1.1rem;">
This player hasn't edited any media files yet. When the player edits content,<br>
all versions will be tracked and displayed here.
</p>
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn"
style="margin-top: 2rem; display: inline-block; background: #7c3aed; color: white; padding: 0.75rem 1.5rem; text-decoration: none; border-radius: 6px; font-size: 1rem;">
← Back to Player Management
</a>
</div>
{% endif %}
<script>
function toggleCard(contentId) {
const card = document.getElementById('card-' + contentId);
const wasExpanded = card.classList.contains('expanded');
// Close all cards
document.querySelectorAll('.expandable-card').forEach(c => {
c.classList.remove('expanded');
});
// If this card wasn't expanded, expand it
if (!wasExpanded) {
card.classList.add('expanded');
}
}
function selectVersion(contentId, version, filename, user, modifiedDate, createdDate, fileUrl) {
// Update preview image
const previewImg = document.getElementById('preview-img-' + contentId);
if (previewImg) {
previewImg.src = fileUrl;
}
// Update download button
const downloadBtn = document.getElementById('download-btn-' + contentId);
if (downloadBtn) {
downloadBtn.href = fileUrl;
}
// Update metadata
document.getElementById('info-filename-' + contentId).textContent = filename;
document.getElementById('info-version-' + contentId).textContent = 'v' + version;
document.getElementById('info-user-' + contentId).textContent = user;
document.getElementById('info-date-' + contentId).textContent = modifiedDate;
document.getElementById('info-created-' + contentId).textContent = createdDate;
// Update active state on version items
document.querySelectorAll('.version-item').forEach(item => {
if (item.id.startsWith('version-' + contentId + '-')) {
item.classList.remove('active');
}
});
document.getElementById('version-' + contentId + '-' + version).classList.add('active');
}
</script>
{% endblock %}

View File

@@ -211,18 +211,155 @@
body.dark-mode .playlist-stats > div > div:first-child { body.dark-mode .playlist-stats > div > div:first-child {
color: #a0aec0; color: #a0aec0;
} }
/* Player Logs Dark Mode */
body.dark-mode .card p[style*="color: #6c757d"] {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] {
background: #2d3748 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p {
color: #e2e8f0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p[style*="color: #6c757d"] {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] small {
color: #a0aec0 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] details summary {
color: #f87171 !important;
}
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] pre {
background: #1a202c !important;
border-color: #4a5568 !important;
color: #e2e8f0 !important;
}
body.dark-mode .card > div[style*="text-align: center"] {
color: #a0aec0 !important;
}
/* Edited Media Cards Dark Mode */
body.dark-mode .card[style*="border: 2px solid #7c3aed"] {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
border-color: #8b5cf6 !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 {
color: #a78bfa !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] {
background: #374151 !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong {
color: #a78bfa !important;
}
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"],
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] {
color: #cbd5e1 !important;
}
body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] {
background: #2d3748 !important;
}
body.dark-mode .card > div[style*="text-align: center"] p {
color: #a0aec0 !important;
}
</style> </style>
<div style="margin-bottom: 2rem;"> <div style="margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: flex-start;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;"> <h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;"> <img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
Manage Player: {{ player.name }} Manage Player: {{ player.name }}
</h1> </h1>
<a href="{{ url_for('players.list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;"> <a href="{{ url_for('players.list') }}" class="btn" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);"> <img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Back to Players Back to Players
</a> </a>
</div> </div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
<div style="background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 1.5rem;">⚠️</span>
Confirm Delete
</h2>
<p style="font-size: 1.1rem; margin: 1.5rem 0;">
Are you sure you want to delete player <strong>"{{ player.name }}"</strong>?
</p>
<p style="color: #dc3545; margin: 1rem 0;">
<strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted.
</p>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 2rem;">
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;">
Cancel
</button>
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;">
<button type="submit" class="btn" style="background: #dc3545;">
Yes, Delete Player
</button>
</form>
</div>
</div>
</div>
<style>
body.dark-mode #deleteModal > div {
background-color: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode #deleteModal h2 {
color: #f87171;
}
body.dark-mode #deleteModal p {
color: #e2e8f0;
}
body.dark-mode #deleteModal p strong {
color: #fbbf24;
}
</style>
<script>
function confirmDelete() {
document.getElementById('deleteModal').style.display = 'block';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
}
// Close modal when clicking outside of it
window.onclick = function(event) {
const modal = document.getElementById('deleteModal');
if (event.target == modal) {
closeDeleteModal();
}
}
// Close modal with ESC key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeDeleteModal();
}
});
</script>
<!-- Player Status Overview --> <!-- Player Status Overview -->
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}"> <div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
<h3 style="display: flex; align-items: center; gap: 0.5rem;"> <h3 style="display: flex; align-items: center; gap: 0.5rem;">
@@ -247,7 +384,7 @@
<p><strong>Hostname:</strong> {{ player.hostname }}</p> <p><strong>Hostname:</strong> {{ player.hostname }}</p>
<p><strong>Last Seen:</strong> <p><strong>Last Seen:</strong>
{% if player.last_seen %} {% if player.last_seen %}
{{ player.last_seen.strftime('%Y-%m-%d %H:%M:%S') }} {{ player.last_seen | localtime('%Y-%m-%d %H:%M:%S') }}
{% else %} {% else %}
Never Never
{% endif %} {% endif %}
@@ -279,7 +416,7 @@
</div> </div>
<div> <div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div> <div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}</div> <div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at | localtime }}</div>
</div> </div>
<div> <div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div> <div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
@@ -334,23 +471,35 @@
</select> </select>
</div> </div>
<div class="info-box neutral"> <div style="border-top: 1px solid #ddd; margin: 1.5rem 0; padding-top: 1.5rem;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem; color: #495057;">🔑 Player Credentials</h4> <h4 style="margin: 0 0 1rem 0; font-size: 0.95rem;">🔑 Authentication Settings</h4>
<div class="credential-item"> <div class="form-group">
<span class="credential-label">Hostname</span> <label for="hostname">Hostname *</label>
<div class="credential-value">{{ player.hostname }}</div> <input type="text" id="hostname" name="hostname" value="{{ player.hostname }}"
required minlength="3" class="form-control"
placeholder="e.g., tv-terasa">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
This is the unique identifier for the player
</small>
</div> </div>
<div class="credential-item"> <div class="form-group">
<span class="credential-label">Auth Code</span> <label for="password">New Password (leave blank to keep current)</label>
<div class="credential-value">{{ player.auth_code }}</div> <input type="password" id="password" name="password" class="form-control"
placeholder="Enter new password">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
🔒 Optional: Set a new password for player authentication
</small>
</div> </div>
<div class="credential-item"> <div class="form-group">
<span class="credential-label">Quick Connect Code (Hashed)</span> <label for="quickconnect_code">Quick Connect Code</label>
<div class="credential-value" style="font-size: 0.75rem;">{{ player.quickconnect_code or 'Not set' }}</div> <input type="text" id="quickconnect_code" name="quickconnect_code" class="form-control"
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">⚠️ This is the hashed version for security</small> placeholder="e.g., 8887779">
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
🔗 Enter the plain text code (e.g., 8887779) - will be hashed automatically
</small>
</div> </div>
</div> </div>
@@ -390,7 +539,7 @@
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p> <p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p> <p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;"> <p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
Updated: {{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }} Updated: {{ current_playlist.updated_at | localtime }}
</p> </p>
</div> </div>
{% else %} {% else %}
@@ -421,6 +570,10 @@
Edit Current Playlist Edit Current Playlist
</a> </a>
{% endif %} {% endif %}
<button onclick="confirmDelete()" class="btn" style="width: 100%; margin-top: 0.5rem; background: #dc3545; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<span style="font-size: 1.2rem;">🗑️</span>
Delete Player
</button>
</div> </div>
</div> </div>
@@ -469,7 +622,7 @@
{% endif %} {% endif %}
</div> </div>
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;"> <small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
{{ log.timestamp.strftime('%m/%d %H:%M') }} {{ log.timestamp | localtime('%m/%d %H:%M') }}
</small> </small>
</div> </div>
</div> </div>
@@ -485,13 +638,118 @@
</div> </div>
<!-- Edited Media Section - Full Width -->
<div class="card" style="margin-top: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<h2 style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
Edited Media on the Player
</h2>
{% if edited_media %}
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
class="btn"
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
onmouseover="this.style.background='#6d28d9'"
onmouseout="this.style.background='#7c3aed'">
📋 View All Edited Media
</a>
{% endif %}
</div>
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
{% if edited_media %}
{% set edited_by_content = {} %}
{% for edit in edited_media %}
{% if edit.content_id not in edited_by_content %}
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'latest_version': edit}}) %}
{% endif %}
{% endfor %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
{% for content_id, data in edited_by_content.items() %}
{% if loop.index <= 3 %}
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
{% set edit = data.latest_version %}
<!-- Image Preview if it's an image -->
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
<div style="width: 100%; height: 200px; overflow: hidden; border-radius: 8px 8px 0 0; background: #000;">
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
alt="{{ edit.new_name }}"
style="width: 100%; height: 100%; object-fit: contain;">
</div>
{% endif %}
<div style="padding: 1rem;">
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem;">
✏️ {{ data.original_name }}
</h3>
<div style="padding: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
<strong style="color: #7c3aed; font-size: 0.95rem;">
Version {{ edit.version }}
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
</strong>
<small style="color: #6c757d; white-space: nowrap;">
{{ edit.created_at | localtime('%m/%d %H:%M') }}
</small>
</div>
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
📄 {{ edit.new_name }}
</p>
{% if edit.user %}
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
👤 {{ edit.user }}
</p>
{% endif %}
{% if edit.time_of_modification %}
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
</p>
{% endif %}
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
target="_blank"
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
onmouseover="this.style.background='#6d28d9'"
onmouseout="this.style.background='#7c3aed'">
📥 View File
</a>
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
download
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
onmouseover="this.style.background='#475569'"
onmouseout="this.style.background='#64748b'">
💾 Download
</a>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
<p style="font-size: 2rem; margin: 0;">📝</p>
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
</div>
{% endif %}
</div>
<!-- Additional Info Section --> <!-- Additional Info Section -->
<div class="card" style="margin-top: 2rem;"> <div class="card" style="margin-top: 2rem;">
<h2> Player Information</h2> <h2> Player Information</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div> <div>
<p><strong>Player ID:</strong> {{ player.id }}</p> <p><strong>Player ID:</strong> {{ player.id }}</p>
<p><strong>Created:</strong> {{ player.created_at.strftime('%Y-%m-%d %H:%M') if player.created_at else 'N/A' }}</p> <p><strong>Created:</strong> {{ (player.created_at | localtime) if player.created_at else 'N/A' }}</p>
</div> </div>
<div> <div>
<p><strong>Orientation:</strong> {{ player.orientation }}</p> <p><strong>Orientation:</strong> {{ player.orientation }}</p>
@@ -500,7 +758,7 @@
<div> <div>
<p><strong>Last Heartbeat:</strong> <p><strong>Last Heartbeat:</strong>
{% if player.last_heartbeat %} {% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M:%S') }} {{ player.last_heartbeat | localtime('%Y-%m-%d %H:%M:%S') }}
{% else %} {% else %}
Never Never
{% endif %} {% endif %}

View File

@@ -96,7 +96,7 @@
<div id="player-container"> <div id="player-container">
<div class="loading" id="loading">Loading playlist...</div> <div class="loading" id="loading">Loading playlist...</div>
<img id="media-display" alt="Content"> <img id="media-display" alt="Content">
<video id="video-display" style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video> <video id="video-display" muted autoplay playsinline style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
<div class="no-content" id="no-content" style="display: none;"> <div class="no-content" id="no-content" style="display: none;">
<p>💭 No content in playlist</p> <p>💭 No content in playlist</p>
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p> <p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
@@ -149,6 +149,7 @@
if (item.type === 'video') { if (item.type === 'video') {
videoDisplay.src = item.url; videoDisplay.src = item.url;
videoDisplay.muted = item.muted !== false; // Muted unless explicitly set to false
videoDisplay.style.display = 'block'; videoDisplay.style.display = 'block';
videoDisplay.play(); videoDisplay.play();

View File

@@ -64,7 +64,7 @@
</tr> </tr>
<tr> <tr>
<td style="padding: 10px; font-weight: bold;">Created:</td> <td style="padding: 10px; font-weight: bold;">Created:</td>
<td style="padding: 10px;">{{ player.created_at.strftime('%Y-%m-%d %H:%M') }}</td> <td style="padding: 10px;">{{ player.created_at | localtime }}</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -188,7 +188,7 @@
{% for feedback in recent_feedback %} {% for feedback in recent_feedback %}
<tr style="border-bottom: 1px solid #dee2e6;"> <tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; white-space: nowrap;"> <td style="padding: 10px; white-space: nowrap;">
<small style="color: #6c757d;">{{ feedback.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small> <small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</td> </td>
<td style="padding: 10px;"> <td style="padding: 10px;">
{% if feedback.status == 'playing' %} {% if feedback.status == 'playing' %}

View File

@@ -162,16 +162,15 @@
{{ player.orientation or 'Landscape' }} {{ player.orientation or 'Landscape' }}
</td> </td>
<td> <td>
{% set status = player_statuses.get(player.id, {}) %} {% if player.is_online %}
{% if status.get('is_online') %}
<span class="status-badge online">Online</span> <span class="status-badge online">Online</span>
{% else %} {% else %}
<span class="status-badge offline">Offline</span> <span class="status-badge offline">Offline</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if player.last_heartbeat %} {% if player.last_seen %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }} {{ player.last_seen | localtime }}
{% else %} {% else %}
<span class="text-muted">Never</span> <span class="text-muted">Never</span>
{% endif %} {% endif %}

View File

@@ -369,6 +369,43 @@
background: #5a1e1e; background: #5a1e1e;
color: #ef5350; 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> </style>
<div class="playlist-container"> <div class="playlist-container">
@@ -433,6 +470,7 @@
<th>Filename</th> <th>Filename</th>
<th style="width: 100px;">Type</th> <th style="width: 100px;">Type</th>
<th style="width: 120px;">Duration (s)</th> <th style="width: 120px;">Duration (s)</th>
<th style="width: 80px;">Audio</th>
<th style="width: 100px;">Size</th> <th style="width: 100px;">Size</th>
<th style="width: 150px;">Actions</th> <th style="width: 150px;">Actions</th>
</tr> </tr>
@@ -479,6 +517,24 @@
</button> </button>
</div> </div>
</td> </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>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
<td> <td>
<form method="POST" <form method="POST"
@@ -764,6 +820,38 @@ function updateTotalDuration() {
} }
}); });
} }
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> </script>
{% endblock %} {% endblock %}

49
check_fix_player.py Normal file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Check and fix player quickconnect code."""
from app import create_app
from app.models import Player
from app.extensions import db
app = create_app()
with app.app_context():
# Find player by hostname
player = Player.query.filter_by(hostname='tv-terasa').first()
if not player:
print("❌ Player 'tv-terasa' NOT FOUND in database!")
print("\nAll registered players:")
all_players = Player.query.all()
for p in all_players:
print(f" - ID={p.id}, Name='{p.name}', Hostname='{p.hostname}'")
else:
print(f"✅ Player found:")
print(f" ID: {player.id}")
print(f" Name: {player.name}")
print(f" Hostname: {player.hostname}")
print(f" Playlist ID: {player.playlist_id}")
print(f" Status: {player.status}")
print(f" QuickConnect Hash: {player.quickconnect_code[:60] if player.quickconnect_code else 'Not set'}...")
# Test the quickconnect code
test_code = "8887779"
print(f"\n🔐 Testing quickconnect code: '{test_code}'")
if player.check_quickconnect_code(test_code):
print(f"✅ Code '{test_code}' is VALID!")
else:
print(f"❌ Code '{test_code}' is INVALID - Hash doesn't match!")
# Update it
print(f"\n🔧 Updating quickconnect code to: '{test_code}'")
player.set_quickconnect_code(test_code)
db.session.commit()
print("✅ QuickConnect code updated successfully!")
print(f" New hash: {player.quickconnect_code[:60]}...")
# Verify the update
if player.check_quickconnect_code(test_code):
print(f"✅ Verification successful - code '{test_code}' now works!")
else:
print(f"❌ Verification failed - something went wrong!")

View File

@@ -8,14 +8,14 @@ mkdir -p /app/instance
mkdir -p /app/app/static/uploads mkdir -p /app/app/static/uploads
# Initialize database if it doesn't exist # Initialize database if it doesn't exist
if [ ! -f /app/instance/digiserver.db ]; then if [ ! -f /app/instance/dashboard.db ]; then
echo "Initializing database..." echo "Initializing database..."
python -c " python -c "
from app.app import create_app from app.app import create_app
from app.extensions import db, bcrypt from app.extensions import db, bcrypt
from app.models import User from app.models import User
app = create_app() app = create_app('production')
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
@@ -49,4 +49,4 @@ exec gunicorn \
--timeout 120 \ --timeout 120 \
--access-logfile - \ --access-logfile - \
--error-logfile - \ --error-logfile - \
"app.app:create_app()" "app.app:create_app('production')"

View File

@@ -3,8 +3,8 @@
# Install emoji fonts for Raspberry Pi # Install emoji fonts for Raspberry Pi
echo "Installing emoji font support for Raspberry Pi..." echo "Installing emoji font support for Raspberry Pi..."
sudo apt-get update apt-get update -qq
sudo apt-get install -y fonts-noto-color-emoji fonts-noto-emoji apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
echo "✅ Emoji fonts installed!" echo "✅ Emoji fonts installed!"
echo "Please restart your browser to see the changes." echo "Please restart your browser to see the changes."

View File

@@ -9,12 +9,6 @@ echo "LibreOffice Installation Script"
echo "======================================" echo "======================================"
echo "" echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "❌ This script must be run as root or with sudo"
exit 1
fi
# Check if already installed # Check if already installed
if command -v libreoffice &> /dev/null; then if command -v libreoffice &> /dev/null; then
VERSION=$(libreoffice --version 2>/dev/null || echo "Unknown") VERSION=$(libreoffice --version 2>/dev/null || echo "Unknown")

View File

@@ -0,0 +1,47 @@
"""Migration: Add edit_on_player_enabled column to playlist_content table."""
import sqlite3
import os
DB_PATH = 'instance/dashboard.db'
def migrate():
"""Add edit_on_player_enabled column to playlist_content."""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return False
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
# Check if column already exists
cursor.execute("PRAGMA table_info(playlist_content)")
columns = [col[1] for col in cursor.fetchall()]
if 'edit_on_player_enabled' in columns:
print("Column 'edit_on_player_enabled' already exists!")
return True
# Add the new column with default value False
print("Adding 'edit_on_player_enabled' column to playlist_content table...")
cursor.execute("""
ALTER TABLE playlist_content
ADD COLUMN edit_on_player_enabled BOOLEAN DEFAULT 0
""")
conn.commit()
print("✅ Migration completed successfully!")
print("Column 'edit_on_player_enabled' added with default value False (0)")
return True
except Exception as e:
conn.rollback()
print(f"❌ Migration failed: {e}")
return False
finally:
conn.close()
if __name__ == '__main__':
migrate()

View File

@@ -0,0 +1,181 @@
# Player Edit Media API
## Overview
This API allows players to upload edited media files back to the server, maintaining version history and automatically updating playlists.
## Endpoint
### POST `/api/player-edit-media`
Upload an edited media file from a player device.
**Authentication Required:** Yes (Bearer token)
**Rate Limit:** 60 requests per 60 seconds
**Content-Type:** `multipart/form-data`
## Request
### Form Data
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `image_file` | File | Yes | The edited image file |
| `metadata` | JSON String | Yes | Metadata about the edit (see below) |
### Metadata JSON Structure
```json
{
"time_of_modification": "2025-12-05T20:30:00Z",
"original_name": "image.jpg",
"new_name": "image_v1.jpg",
"version": 1,
"user": "player_user_name"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `time_of_modification` | ISO 8601 DateTime | Yes | When the edit was made |
| `original_name` | String | Yes | Original filename (must exist in content) |
| `new_name` | String | Yes | New filename with version suffix |
| `version` | Integer | Yes | Version number (1, 2, 3, etc.) |
| `user` | String | No | User who made the edit |
## Response
### Success (200 OK)
```json
{
"success": true,
"message": "Edited media received and processed",
"edit_id": 123,
"version": 1,
"new_playlist_version": 5
}
```
### Error Responses
#### 400 Bad Request
```json
{
"error": "No image file provided"
}
```
#### 404 Not Found
```json
{
"error": "Original content not found: image.jpg"
}
```
#### 500 Internal Server Error
```json
{
"error": "Internal server error"
}
```
## Workflow
1. **Player edits media** - User edits an image/PDF/PPTX on the player device
2. **Player uploads** - Player sends edited file + metadata to this endpoint
3. **Server processes**:
- Saves edited file to `/static/uploads/edited_media/<content_id>/<new_name>`
- Saves metadata JSON to `/static/uploads/edited_media/<content_id>/<new_name>_metadata.json`
- Replaces original file in `/static/uploads/` with edited version
- Creates database record in `player_edit` table
- Increments playlist version to trigger player refresh
- Clears playlist cache
4. **Player refreshes** - Next playlist check shows updated media
## Version History
Each edit is saved with a version number:
- `image.jpg``image_v1.jpg` (first edit)
- `image.jpg``image_v2.jpg` (second edit)
- etc.
All versions are preserved in the `edited_media/<content_id>/` folder.
## Example cURL Request
```bash
# First, authenticate to get token
TOKEN=$(curl -X POST http://server/api/auth/authenticate \
-H "Content-Type: application/json" \
-d '{"hostname": "player-1", "password": "password123"}' \
| jq -r '.token')
# Upload edited media
curl -X POST http://server/api/player-edit-media \
-H "Authorization: Bearer $TOKEN" \
-F "image_file=@edited_image_v1.jpg" \
-F 'metadata={"time_of_modification":"2025-12-05T20:30:00Z","original_name":"image.jpg","new_name":"image_v1.jpg","version":1,"user":"john"}'
```
## Python Example
```python
import requests
import json
# Authenticate
auth_response = requests.post(
'http://server/api/auth/authenticate',
json={'hostname': 'player-1', 'password': 'password123'}
)
token = auth_response.json()['token']
# Prepare metadata
metadata = {
'time_of_modification': '2025-12-05T20:30:00Z',
'original_name': 'image.jpg',
'new_name': 'image_v1.jpg',
'version': 1,
'user': 'john'
}
# Upload edited file
with open('edited_image_v1.jpg', 'rb') as f:
response = requests.post(
'http://server/api/player-edit-media',
headers={'Authorization': f'Bearer {token}'},
files={'image_file': f},
data={'metadata': json.dumps(metadata)}
)
print(response.json())
```
## Database Schema
### player_edit Table
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER | Primary key |
| player_id | INTEGER | Foreign key to player |
| content_id | INTEGER | Foreign key to content |
| original_name | VARCHAR(255) | Original filename |
| new_name | VARCHAR(255) | New filename with version |
| version | INTEGER | Version number |
| user | VARCHAR(255) | User who made the edit |
| time_of_modification | DATETIME | When edit was made |
| metadata_path | VARCHAR(512) | Path to metadata JSON |
| edited_file_path | VARCHAR(512) | Path to edited file |
| created_at | DATETIME | Record creation time |
## UI Display
Edited media history is displayed on the player management page under the "Edited Media on the Player" card, showing:
- Original filename
- Version number
- Editor name
- Modification time
- Link to view edited file