updated features to upload pptx files

This commit is contained in:
DigiServer Developer
2025-11-15 01:26:12 +02:00
parent 9d4f932a95
commit 930a5bf636
24 changed files with 1963 additions and 2218 deletions

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Optional
from app.extensions import db, bcrypt
from app.models import User, Player, Group, Content, ServerLog
from app.models import User, Player, Group, Content, ServerLog, Playlist
from app.utils.logger import log_action
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -38,7 +38,7 @@ def admin_panel():
# Get statistics
total_users = User.query.count()
total_players = Player.query.count()
total_groups = Group.query.count()
total_playlists = Playlist.query.count()
total_content = Content.query.count()
# Get recent logs
@@ -62,7 +62,7 @@ def admin_panel():
return render_template('admin/admin.html',
total_users=total_users,
total_players=total_players,
total_groups=total_groups,
total_playlists=total_playlists,
total_content=total_content,
storage_mb=storage_mb,
users=users,
@@ -347,3 +347,146 @@ def system_info():
except Exception as e:
log_action('error', f'Error getting system info: {str(e)}')
return jsonify({'error': str(e)}), 500
@admin_bp.route('/leftover-media')
@login_required
@admin_required
def leftover_media():
"""Display leftover media files not assigned to any playlist."""
from app.models.playlist import playlist_content
from sqlalchemy import select
try:
# Get all content IDs that are in playlists
stmt = select(playlist_content.c.content_id).distinct()
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
# Get all content
all_content = Content.query.all()
# Filter content not in any playlist
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
# Separate by type
leftover_images = [c for c in leftover_content if c.content_type == 'image']
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
# Calculate storage
total_leftover_size = sum(c.file_size for c in leftover_content)
images_size = sum(c.file_size for c in leftover_images)
videos_size = sum(c.file_size for c in leftover_videos)
pdfs_size = sum(c.file_size for c in leftover_pdfs)
pptx_size = sum(c.file_size for c in leftover_pptx)
return render_template('admin/leftover_media.html',
leftover_images=leftover_images,
leftover_videos=leftover_videos,
leftover_pdfs=leftover_pdfs,
leftover_pptx=leftover_pptx,
total_leftover=len(leftover_content),
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
images_size_mb=images_size / (1024 * 1024),
videos_size_mb=videos_size / (1024 * 1024),
pdfs_size_mb=pdfs_size / (1024 * 1024),
pptx_size_mb=pptx_size / (1024 * 1024))
except Exception as e:
log_action('error', f'Error loading leftover media: {str(e)}')
flash('Error loading leftover media.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/delete-leftover-images', methods=['POST'])
@login_required
@admin_required
def delete_leftover_images():
"""Delete all leftover images that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover image content
leftover_images = db.session.query(Content).filter(
Content.media_type == 'image',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_images:
try:
# Delete physical file
if content.file_path:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
if os.path.exists(file_path):
os.remove(file_path)
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover images: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
@login_required
@admin_required
def delete_leftover_videos():
"""Delete all leftover videos that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover video content
leftover_videos = db.session.query(Content).filter(
Content.media_type == 'video',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_videos:
try:
# Delete physical file
if content.file_path:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
if os.path.exists(file_path):
os.remove(file_path)
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))

View File

@@ -243,10 +243,222 @@ def upload_media_page():
return render_template('content/upload_media.html', playlists=playlists)
def process_image_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process and optimize image files."""
try:
from PIL import Image
# Open and optimize image
img = Image.open(filepath)
# Convert RGBA to RGB for JPEGs
if img.mode == 'RGBA' and filename.lower().endswith(('.jpg', '.jpeg')):
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
rgb_img.paste(img, mask=img.split()[3])
img = rgb_img
# Resize if too large (max 1920x1080 for display efficiency)
max_size = (1920, 1080)
if img.width > max_size[0] or img.height > max_size[1]:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
img.save(filepath, optimize=True, quality=85)
log_action('info', f'Optimized image: {filename}')
return True, "Image processed successfully"
except Exception as e:
return False, f"Image processing error: {str(e)}"
def process_video_file_extended(filepath: str, filename: str) -> tuple[bool, str]:
"""Process and optimize video files for Raspberry Pi playback."""
try:
# Basic video validation
import subprocess
# Check if video is playable
result = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height',
'-of', 'default=noprint_wrappers=1', filepath],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
log_action('info', f'Video validated: {filename}')
return True, "Video validated successfully"
else:
return False, "Video validation failed"
except Exception as e:
# If ffprobe not available, just accept the video
log_action('warning', f'Video validation skipped (ffprobe unavailable): {filename}')
return True, "Video accepted without validation"
def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PDF files."""
try:
# Basic PDF validation - check if it's a valid PDF
with open(filepath, 'rb') as f:
header = f.read(5)
if header != b'%PDF-':
return False, "Invalid PDF file"
log_action('info', f'PDF validated: {filename}')
return True, "PDF processed successfully"
except Exception as e:
return False, f"PDF processing error: {str(e)}"
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PowerPoint presentation files by converting slides to images."""
try:
import subprocess
import tempfile
import shutil
from pathlib import Path
# Basic validation - check file exists and has content
file_size = os.path.getsize(filepath)
if file_size < 1024: # Less than 1KB is suspicious
return False, "File too small to be a valid presentation"
# Check if LibreOffice is available
libreoffice_paths = [
'/usr/bin/libreoffice',
'/usr/bin/soffice',
'/snap/bin/libreoffice',
'libreoffice', # Try in PATH
'soffice'
]
libreoffice_cmd = None
for cmd in libreoffice_paths:
try:
result = subprocess.run([cmd, '--version'],
capture_output=True,
timeout=5)
if result.returncode == 0:
libreoffice_cmd = cmd
log_action('info', f'Found LibreOffice at: {cmd}')
break
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
if not libreoffice_cmd:
log_action('warning', f'LibreOffice not found, skipping slide conversion for: {filename}')
return True, "Presentation accepted without conversion (LibreOffice unavailable)"
# Create temporary directory for conversion
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Copy presentation to temp directory
temp_ppt = temp_path / filename
shutil.copy2(filepath, temp_ppt)
# Convert presentation to images (PNG format)
# Using LibreOffice headless mode with custom resolution
convert_cmd = [
libreoffice_cmd,
'--headless',
'--convert-to', 'png',
'--outdir', str(temp_path),
str(temp_ppt)
]
log_action('info', f'Converting presentation to images: {filename}')
try:
result = subprocess.run(
convert_cmd,
capture_output=True,
text=True,
timeout=120 # 2 minutes timeout
)
if result.returncode != 0:
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (conversion failed)"
# Find generated PNG files
png_files = sorted(temp_path.glob('*.png'))
if not png_files:
log_action('warning', f'No images generated from presentation: {filename}')
return True, "Presentation accepted without images"
# Get upload folder from app config
upload_folder = current_app.config['UPLOAD_FOLDER']
base_name = os.path.splitext(filename)[0]
# Move converted images to upload folder
slide_count = 0
for idx, png_file in enumerate(png_files, start=1):
# Create descriptive filename
slide_filename = f"{base_name}_slide_{idx:03d}.png"
destination = os.path.join(upload_folder, slide_filename)
shutil.move(str(png_file), destination)
# Optimize the image to Full HD (1920x1080)
optimize_image_to_fullhd(destination)
slide_count += 1
log_action('info', f'Converted {slide_count} slides from {filename} to images')
# Remove original PPTX file as we now have the images
os.remove(filepath)
return True, f"Presentation converted to {slide_count} Full HD images"
except subprocess.TimeoutExpired:
log_action('error', f'LibreOffice conversion timeout for: {filename}')
return True, "Presentation accepted without conversion (timeout)"
except Exception as e:
log_action('error', f'Presentation processing error: {str(e)}')
return False, f"Presentation processing error: {str(e)}"
def optimize_image_to_fullhd(filepath: str) -> bool:
"""Optimize and resize image to Full HD (1920x1080) maintaining aspect ratio."""
try:
from PIL import Image
img = Image.open(filepath)
# Target Full HD resolution
target_size = (1920, 1080)
# Calculate resize maintaining aspect ratio
img.thumbnail(target_size, Image.Resampling.LANCZOS)
# Create Full HD canvas with white background
fullhd_img = Image.new('RGB', target_size, (255, 255, 255))
# Center the image on the canvas
x = (target_size[0] - img.width) // 2
y = (target_size[1] - img.height) // 2
if img.mode == 'RGBA':
fullhd_img.paste(img, (x, y), img)
else:
fullhd_img.paste(img, (x, y))
# Save optimized image
fullhd_img.save(filepath, 'PNG', optimize=True)
return True
except Exception as e:
log_action('error', f'Image optimization error: {str(e)}')
return False
@content_bp.route('/upload-media', methods=['POST'])
@login_required
def upload_media():
"""Upload media files to library."""
"""Upload media files to library with type-specific processing."""
try:
files = request.files.getlist('files')
content_type = request.form.get('content_type', 'image')
@@ -261,6 +473,7 @@ def upload_media():
os.makedirs(upload_folder, exist_ok=True)
uploaded_count = 0
processing_errors = []
for file in files:
if file.filename == '':
@@ -275,64 +488,138 @@ def upload_media():
log_action('warning', f'File {filename} already exists, skipping')
continue
# Save file
# Save file first
file.save(filepath)
# Determine content type from extension
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
# Process file based on type
processing_success = True
processing_message = ""
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']:
detected_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
processing_success, processing_message = process_image_file(filepath, filename)
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
detected_type = 'video'
# Process video for Raspberry Pi
success, message = process_video_file(filepath, os.urandom(8).hex())
if not success:
log_action('error', f'Video processing failed: {message}')
processing_success, processing_message = process_video_file_extended(filepath, filename)
elif file_ext == 'pdf':
detected_type = 'pdf'
processing_success, processing_message = process_pdf_file(filepath, filename)
elif file_ext in ['ppt', 'pptx']:
detected_type = 'pptx'
processing_success, processing_message = process_presentation_file(filepath, filename)
# For presentations, slides are converted to individual images
# We need to add each slide image as a separate content item
if processing_success and "converted to" in processing_message.lower():
# Find all slide images that were created
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
# Add each slide as separate content
for slide_file in slide_files:
slide_filename = os.path.basename(slide_file)
# Create content record for slide
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()
# Add to playlist if specified
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
)
db.session.execute(stmt)
uploaded_count += 1
# Increment playlist version if slides were added
if playlist_id and slide_files:
playlist.version += 1
continue # Skip normal content creation below
else:
detected_type = 'other'
# Create content record
content = Content(
filename=filename,
content_type=detected_type,
duration=duration,
file_size=os.path.getsize(filepath)
)
db.session.add(content)
db.session.flush() # Get content ID
if not processing_success:
processing_errors.append(f"{filename}: {processing_message}")
if os.path.exists(filepath):
os.remove(filepath) # Remove failed file
log_action('error', f'Processing failed for {filename}: {processing_message}')
continue
# Add to playlist if specified
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if playlist:
# Get max position
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
# Add to playlist
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_position + 1,
duration=duration
)
db.session.execute(stmt)
# Increment playlist version
playlist.version += 1
uploaded_count += 1
# Create content record (for non-presentation files or failed conversions)
if 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() # Get content ID
# Add to playlist if specified
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if playlist:
# Get max position
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
# Add to playlist
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_position + 1,
duration=duration
)
db.session.execute(stmt)
# Increment playlist version
playlist.version += 1
uploaded_count += 1
db.session.commit()
cache.clear()
log_action('info', f'Uploaded {uploaded_count} media files')
if playlist_id:
# Show appropriate flash message
if processing_errors:
error_summary = '; '.join(processing_errors[:3])
if len(processing_errors) > 3:
error_summary += f' and {len(processing_errors) - 3} more...'
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
elif playlist_id:
playlist = Playlist.query.get(playlist_id)
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
else:

View File

@@ -213,31 +213,8 @@ def regenerate_auth_code(player_id: int):
@players_bp.route('/<int:player_id>')
@login_required
def player_page(player_id: int):
"""Display player page with content and controls."""
try:
player = Player.query.get_or_404(player_id)
# Get player's playlist
playlist = get_player_playlist(player_id)
# Get player status
status_info = get_player_status_info(player_id)
# Get recent feedback
recent_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.limit(10)\
.all()
return render_template('players/player_page.html',
player=player,
playlist=playlist,
status_info=status_info,
recent_feedback=recent_feedback)
except Exception as e:
log_action('error', f'Error loading player page: {str(e)}')
flash('Error loading player page.', 'danger')
return redirect(url_for('players.list'))
"""Redirect to manage player page (combined view)."""
return redirect(url_for('players.manage_player', player_id=player_id))
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
@@ -347,7 +324,7 @@ def player_fullscreen(player_id: int):
@cache.memoize(timeout=300) # Cache for 5 minutes
def get_player_playlist(player_id: int) -> List[dict]:
"""Get playlist for a player based on their direct content assignment.
"""Get playlist for a player based on their assigned playlist.
Args:
player_id: The player's database ID
@@ -356,23 +333,26 @@ def get_player_playlist(player_id: int) -> List[dict]:
List of content dictionaries with url, type, duration, and position
"""
player = Player.query.get(player_id)
if not player:
if not player or not player.playlist_id:
return []
# Get content directly assigned to this player
contents = Content.query.filter_by(player_id=player_id)\
.order_by(Content.position, Content.uploaded_at)\
.all()
# Get the player's assigned playlist
playlist_obj = Playlist.query.get(player.playlist_id)
if not playlist_obj:
return []
# Get ordered content from the playlist
ordered_content = playlist_obj.get_content_ordered()
# Build playlist
playlist = []
for content in contents:
for content in ordered_content:
playlist.append({
'id': content.id,
'url': url_for('static', filename=f'uploads/{content.filename}'),
'type': content.content_type,
'duration': content.duration or 10, # Default 10 seconds if not set
'position': content.position,
'duration': getattr(content, '_playlist_duration', content.duration or 10),
'position': getattr(content, '_playlist_position', 0),
'filename': content.filename
})

View File

@@ -2,11 +2,12 @@
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from sqlalchemy import desc
from sqlalchemy import desc, update
import os
from app.extensions import db, cache
from app.models import Player, Content
from app.models import Player, Content, Playlist
from app.models.playlist import playlist_content
from app.utils.logger import log_action
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@@ -18,20 +19,22 @@ def manage_playlist(player_id: int):
"""Manage playlist for a specific player."""
player = Player.query.get_or_404(player_id)
# Get all content for this player, ordered by position
playlist_content = Content.query.filter_by(
player_id=player_id
).order_by(Content.position).all()
# Get content from player's assigned playlist
playlist_items = []
if player.playlist_id:
playlist = Playlist.query.get(player.playlist_id)
if playlist:
playlist_items = playlist.get_content_ordered()
# Get available content (files not already in this player's playlist)
all_files = db.session.query(Content.filename).distinct().all()
playlist_filenames = {c.filename for c in playlist_content}
available_files = [f[0] for f in all_files if f[0] not in playlist_filenames]
# Get available content (all content not in current playlist)
all_content = Content.query.all()
playlist_content_ids = {item.id for item in playlist_items}
available_content = [c for c in all_content if c.id not in playlist_content_ids]
return render_template('playlist/manage_playlist.html',
player=player,
playlist_content=playlist_content,
available_files=available_files)
playlist_content=playlist_items,
available_content=available_content)
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
@@ -40,44 +43,46 @@ def add_to_playlist(player_id: int):
"""Add content to player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
filename = request.form.get('filename')
content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10)
if not filename:
flash('Please provide a filename.', 'warning')
if not content_id:
flash('Please select content.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
# Get max position
max_position = db.session.query(db.func.max(Content.position)).filter_by(
player_id=player_id
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist.id
)
).scalar() or 0
# Get file info from existing content
existing_content = Content.query.filter_by(filename=filename).first()
if not existing_content:
flash('File not found.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
# Create new content entry for this player
new_content = Content(
filename=filename,
content_type=existing_content.content_type,
duration=duration,
file_size=existing_content.file_size,
player_id=player_id,
position=max_position + 1
# Add to playlist_content association table
stmt = playlist_content.insert().values(
playlist_id=playlist.id,
content_id=content.id,
position=max_pos + 1,
duration=duration
)
db.session.add(new_content)
db.session.execute(stmt)
# Increment playlist version
player.playlist_version += 1
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added "{filename}" to playlist for player "{player.name}"')
flash(f'Added "{filename}" to playlist.', 'success')
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
@@ -92,28 +97,41 @@ def add_to_playlist(player_id: int):
def remove_from_playlist(player_id: int, content_id: int):
"""Remove content from player's playlist."""
player = Player.query.get_or_404(player_id)
content = Content.query.get_or_404(content_id)
if content.player_id != player_id:
flash('Content does not belong to this player.', 'danger')
if not player.playlist_id:
flash('Player has no playlist assigned.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
filename = content.filename
# Delete content
db.session.delete(content)
# Remove from playlist_content association table
from sqlalchemy import delete
stmt = delete(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
# Reorder remaining content
remaining_content = Content.query.filter_by(
player_id=player_id
).order_by(Content.position).all()
from sqlalchemy import select
remaining = db.session.execute(
select(playlist_content.c.content_id, playlist_content.c.position).where(
playlist_content.c.playlist_id == playlist.id
).order_by(playlist_content.c.position)
).fetchall()
for idx, item in enumerate(remaining_content, start=1):
item.position = idx
for idx, row in enumerate(remaining, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == row.content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
player.playlist_version += 1
playlist.increment_version()
db.session.commit()
cache.clear()
@@ -135,7 +153,12 @@ def reorder_playlist(player_id: int):
"""Reorder playlist items."""
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)
# Get new order from JSON
data = request.get_json()
content_ids = data.get('content_ids', [])
@@ -143,24 +166,26 @@ def reorder_playlist(player_id: int):
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
# Update positions
# Update positions in association table
for idx, content_id in enumerate(content_ids, start=1):
content = Content.query.get(content_id)
if content and content.player_id == player_id:
content.position = idx
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
player.playlist_version += 1
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})')
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
return jsonify({
'success': True,
'message': 'Playlist reordered successfully',
'version': player.playlist_version
'version': playlist.version
})
except Exception as e:
@@ -174,19 +199,28 @@ def reorder_playlist(player_id: int):
def update_duration(player_id: int, content_id: int):
"""Update content duration in playlist."""
player = Player.query.get_or_404(player_id)
content = Content.query.get_or_404(content_id)
if content.player_id != player_id:
return jsonify({'success': False, 'message': 'Content does not belong to this player'}), 403
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)
duration = request.form.get('duration', type=int)
if not duration or duration < 1:
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
content.duration = duration
player.playlist_version += 1
# Update duration in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(duration=duration)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
@@ -196,7 +230,7 @@ def update_duration(player_id: int, content_id: int):
return jsonify({
'success': True,
'message': 'Duration updated',
'version': player.playlist_version
'version': playlist.version
})
except Exception as e:
@@ -211,12 +245,22 @@ def clear_playlist(player_id: int):
"""Clear all content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
# Delete all content for this player
Content.query.filter_by(player_id=player_id).delete()
playlist = Playlist.query.get(player.playlist_id)
# Delete all content from playlist
from sqlalchemy import delete
stmt = delete(playlist_content).where(
playlist_content.c.playlist_id == playlist.id
)
db.session.execute(stmt)
# Increment playlist version
player.playlist_version += 1
playlist.increment_version()
db.session.commit()
cache.clear()

View File

@@ -16,10 +16,11 @@ class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
# File Upload
# File Upload - use absolute paths
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
UPLOAD_FOLDER = 'static/uploads'
UPLOAD_FOLDERLOGO = 'static/resurse'
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
# Session

View File

@@ -11,24 +11,39 @@
<h2>📊 System Overview</h2>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-label">Total Users:</span>
<span class="stat-value">{{ total_users or 0 }}</span>
<div class="stat-icon">👥</div>
<div class="stat-content">
<span class="stat-label">Total Users</span>
<span class="stat-value">{{ total_users or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Total Players:</span>
<span class="stat-value">{{ total_players or 0 }}</span>
<div class="stat-icon">🖥️</div>
<div class="stat-content">
<span class="stat-label">Total Players</span>
<span class="stat-value">{{ total_players or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Total Groups:</span>
<span class="stat-value">{{ total_groups or 0 }}</span>
<div class="stat-icon">📋</div>
<div class="stat-content">
<span class="stat-label">Total Playlists</span>
<span class="stat-value">{{ total_playlists or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Total Content:</span>
<span class="stat-value">{{ total_content or 0 }}</span>
<div class="stat-icon">📁</div>
<div class="stat-content">
<span class="stat-label">Media Files</span>
<span class="stat-value">{{ total_content or 0 }}</span>
</div>
</div>
<div class="stat-item">
<span class="stat-label">Storage Used:</span>
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
<div class="stat-icon">💾</div>
<div class="stat-content">
<span class="stat-label">Storage Used</span>
<span class="stat-value">{{ storage_mb or 0 }} MB</span>
</div>
</div>
</div>
</div>
@@ -44,13 +59,24 @@
</div>
</div>
<!-- Leftover Media Management Card -->
<div class="card management-card">
<h2>🗑️ Manage Leftover Media</h2>
<p>Clean up media files not assigned to any playlist</p>
<div class="card-actions">
<a href="{{ url_for('admin.leftover_media') }}" class="btn btn-warning">
Manage Leftover Files
</a>
</div>
</div>
<!-- Quick Actions Card -->
<div class="card">
<h2>⚡ Quick Actions</h2>
<div class="quick-actions">
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">View Players</a>
<a href="{{ url_for('groups.groups_list') }}" class="btn btn-secondary">View Groups</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">View Content</a>
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
</div>
</div>
</div>
@@ -71,22 +97,61 @@
.stat-item {
display: flex;
justify-content: space-between;
padding: 10px;
align-items: center;
gap: 12px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
border-radius: 8px;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
body.dark-mode .stat-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.stat-icon {
font-size: 2rem;
line-height: 1;
}
.stat-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.stat-label {
font-weight: 500;
font-size: 0.85rem;
color: #666;
}
body.dark-mode .stat-label {
color: #a0aec0;
}
.stat-value {
font-weight: bold;
font-size: 1.5rem;
color: #2c3e50;
}
body.dark-mode .stat-value {
color: #e2e8f0;
}
.management-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;

View File

@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %}
{% block content %}
<div style="max-width: 1400px; margin: 0 auto;">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
🗑️ Manage Leftover Media
</h1>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin
</a>
</div>
<!-- Overview Stats -->
<div class="card" style="margin-bottom: 30px;">
<h2>📊 Overview</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Media files that are not assigned to any playlist. These can be safely deleted to free up storage.
</p>
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Leftover Files</div>
<div style="font-size: 24px; font-weight: bold;">{{ total_leftover }}</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Size</div>
<div style="font-size: 24px; font-weight: bold;">{{ "%.2f"|format(total_leftover_size_mb) }} MB</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Images</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Videos</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)</div>
</div>
</div>
</div>
<!-- Images Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>📷 Leftover Images ({{ leftover_images|length }})</h2>
{% if leftover_images %}
<form method="POST" action="{{ url_for('admin.delete_leftover_images') }}"
onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_images|length }} leftover images? This cannot be undone!');"
style="display: inline;">
<button type="submit" class="btn btn-danger">
🗑️ Delete All Images
</button>
</form>
{% endif %}
</div>
{% if leftover_images %}
<div style="max-height: 400px; overflow-y: auto;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for img in leftover_images %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📷 {{ img.filename }}</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.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover images found. All images are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- Videos Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="margin: 0;">🎥 Leftover Videos ({{ leftover_videos|length }})</h2>
{% if leftover_videos %}
<form method="POST" action="{{ url_for('admin.delete_leftover_videos') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_videos|length }} leftover videos? This action cannot be undone!');">
<button type="submit" class="btn btn-danger" style="padding: 8px 16px; font-size: 14px;">
🗑️ Delete All Videos
</button>
</form>
{% endif %}
</div>
{% if leftover_videos %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for video in leftover_videos %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">🎥 {{ video.filename }}</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.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover videos found. All videos are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- PDFs Section -->
<div class="card" style="margin-bottom: 30px;">
<h2>📄 Leftover PDFs ({{ leftover_pdfs|length }})</h2>
{% if leftover_pdfs %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for pdf in leftover_pdfs %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📄 {{ pdf.filename }}</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.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover PDFs found. All PDFs are assigned to playlists!</p>
</div>
{% endif %}
</div>
</div>
<style>
body.dark-mode .card {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2 {
color: #e2e8f0;
}
body.dark-mode .stat-item {
background: #1a202c !important;
color: #e2e8f0;
}
body.dark-mode .table thead {
background: #1a202c !important;
}
body.dark-mode .table th,
body.dark-mode .table td {
color: #e2e8f0;
border-color: #4a5568 !important;
}
body.dark-mode .table tr {
border-color: #4a5568 !important;
}
body.dark-mode .table tr:hover {
background: #1a202c;
}
</style>
{% endblock %}

View File

@@ -444,10 +444,19 @@
{% endif %}
</td>
<td style="padding: 12px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm">
👁️ View
</a>
<div style="display: flex; gap: 0.5rem;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm">
⚙️ Manage
</a>
{% if player.playlist_id %}
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" target="_blank"
title="View live content preview">
🖥️ Live
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}

View File

@@ -5,14 +5,15 @@
{% block content %}
<style>
.upload-container {
max-width: 900px;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.upload-zone {
border: 3px dashed #ced4da;
border-radius: 12px;
padding: 60px 40px;
border: 2px dashed #ced4da;
border-radius: 8px;
padding: 25px 20px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
@@ -54,18 +55,21 @@
}
.file-list {
margin-top: 20px;
margin-top: 15px;
max-height: 200px;
overflow-y: auto;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
padding: 8px 10px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 10px;
border-radius: 4px;
margin-bottom: 6px;
font-size: 13px;
}
body.dark-mode .file-item {
@@ -80,7 +84,7 @@
}
.file-icon {
font-size: 24px;
font-size: 20px;
}
.remove-file {
@@ -97,7 +101,7 @@
}
.form-group {
margin-bottom: 20px;
margin-bottom: 15px;
}
.form-group label {
@@ -205,106 +209,100 @@
</style>
<div class="upload-container">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 32px; height: 32px;">
Upload Media Files
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
Upload Media
</h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: 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);">
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Back to Playlists
</a>
</div>
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
<!-- Playlist Selector -->
<div class="card" style="margin-bottom: 30px;">
<h2 style="margin-bottom: 15px;">📋 Select Target Playlist (Optional)</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Choose a playlist to directly add uploaded files, or leave blank to add to media library only.
</p>
<!-- Compact Two-Column Layout -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="form-group">
<label for="playlist_id">Target Playlist</label>
<select name="playlist_id" id="playlist_id" class="form-control">
<option value="">-- Media Library Only (Don't add to playlist) --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) - v{{ playlist.version }} - {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;">
💡 Tip: You can add files to playlists later from the media library
</small>
</div>
</div>
<!-- Upload Zone -->
<div class="card" style="margin-bottom: 30px;">
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
Select Files
</h2>
<div class="upload-zone" id="upload-zone">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3; margin-bottom: 20px;">
<h3 style="margin-bottom: 10px;">Drag and Drop Files Here</h3>
<p style="color: #6c757d; margin: 15px 0;">or</p>
<div class="file-input-wrapper">
<label for="file-input" class="btn btn-primary" 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);">
Browse Files
</label>
<input type="file" id="file-input" name="files" multiple
accept="image/*,video/*,.pdf,.ppt,.pptx">
<!-- Left Column: Upload Zone -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
Select Files
</h2>
<div class="upload-zone" id="upload-zone">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
<h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
<p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
<div class="file-input-wrapper">
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Browse
</label>
<input type="file" id="file-input" name="files" multiple
accept="image/*,video/*,.pdf,.ppt,.pptx">
</div>
<p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
<strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
</p>
</div>
<p style="font-size: 14px; color: #999; margin-top: 20px;">
<strong>Supported formats:</strong><br>
Images: JPG, PNG, GIF, BMP<br>
Videos: MP4, AVI, MOV, MKV, WEBM<br>
Documents: PDF, PPT, PPTX
</p>
<div id="file-list" class="file-list"></div>
</div>
<div id="file-list" class="file-list"></div>
</div>
<!-- Upload Settings -->
<div class="card" style="margin-bottom: 30px;">
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Upload Settings
</h2>
<div class="form-group">
<label for="content_type">Media Type</label>
<select name="content_type" id="content_type" class="form-control">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF Document</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;">
This will be auto-detected from file extension
</small>
</div>
<div class="form-group">
<label for="duration">Default Duration (seconds)</label>
<input type="number" name="duration" id="duration" class="form-control"
value="10" min="1" max="300">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long each item should display (for images and PDFs)
</small>
<!-- Right Column: Settings -->
<div class="card">
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
Upload Settings
</h2>
<div class="form-group">
<label for="playlist_id">Target Playlist (Optional)</label>
<select name="playlist_id" id="playlist_id" class="form-control">
<option value="">-- Media Library Only --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
💡 Add to playlists later if needed
</small>
</div>
<div class="form-group">
<label for="content_type">Media Type</label>
<select name="content_type" id="content_type" class="form-control">
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="pptx">PPTX</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Auto-detected from file extension
</small>
</div>
<div class="form-group">
<label for="duration">Default Duration (seconds)</label>
<input type="number" name="duration" id="duration" class="form-control"
value="10" min="1" max="300">
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
Display time for images and PDFs
</small>
</div>
<!-- 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;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Files
</button>
</div>
</div>
<!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px; filter: brightness(0) invert(1);">
Upload Files
</button>
</form>
</div>
@@ -350,18 +348,97 @@
// Handle file input
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
console.log('File input changed, files:', e.target.files.length);
if (e.target.files.length > 0) {
handleFiles(e.target.files);
}
});
// Click upload zone to trigger file input
uploadZone.addEventListener('click', () => {
// Click upload zone to trigger file input (but not if clicking on the label)
uploadZone.addEventListener('click', (e) => {
// Don't trigger if clicking on the label or button
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
return;
}
fileInput.click();
});
function handleFiles(files) {
console.log('handleFiles called with', files.length, 'file(s)');
selectedFiles = Array.from(files);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
// Auto-detect media type and duration from first file
if (selectedFiles.length > 0) {
console.log('Auto-detecting for first file:', selectedFiles[0].name);
autoDetectMediaType(selectedFiles[0]);
autoDetectDuration(selectedFiles[0]);
}
}
function autoDetectMediaType(file) {
const ext = file.name.split('.').pop().toLowerCase();
const contentTypeSelect = document.getElementById('content_type');
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
contentTypeSelect.value = 'image';
console.log('Set type to: image');
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
contentTypeSelect.value = 'video';
console.log('Set type to: video');
} else if (ext === 'pdf') {
contentTypeSelect.value = 'pdf';
console.log('Set type to: pdf');
} else if (['ppt', 'pptx'].includes(ext)) {
contentTypeSelect.value = 'pptx';
console.log('Set type to: pptx');
}
}
function autoDetectDuration(file) {
const ext = file.name.split('.').pop().toLowerCase();
const durationInput = document.getElementById('duration');
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
// For videos, try to get actual duration
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
console.log('Processing as video...');
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.ceil(video.duration);
console.log('Video duration detected:', duration, 'seconds');
if (duration && duration > 0) {
durationInput.value = duration;
}
};
video.onerror = function() {
console.log('Video loading error, using default 30s');
window.URL.revokeObjectURL(video.src);
durationInput.value = 30; // Default for videos if can't read duration
};
video.src = URL.createObjectURL(file);
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
// Images: default 10 seconds
console.log('Setting image duration: 10s');
durationInput.value = 10;
} else if (ext === 'pdf') {
// PDFs: default 15 seconds per page (estimate)
console.log('Setting PDF duration: 15s');
durationInput.value = 15;
} else if (['ppt', 'pptx'].includes(ext)) {
// Presentations: default 20 seconds per slide (estimate)
console.log('Setting presentation duration: 20s');
durationInput.value = 20;
}
}
function displayFiles() {

View File

@@ -3,6 +3,215 @@
{% block title %}Manage Player - {{ player.name }}{% endblock %}
{% block content %}
<style>
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
outline: none;
}
.info-box {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.info-box.neutral {
background: #f8f9fa;
}
body.dark-mode .info-box.neutral {
background: #1a202c;
border: 1px solid #4a5568;
}
.info-box.success {
background: #d4edda;
color: #155724;
}
body.dark-mode .info-box.success {
background: #1a4d2e;
color: #86efac;
border: 1px solid #48bb78;
}
.info-box.warning {
background: #fff3cd;
color: #856404;
}
body.dark-mode .info-box.warning {
background: #4a3800;
color: #fbbf24;
border: 1px solid #ecc94b;
}
.log-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
background: #f8f9fa;
border-left: 4px solid;
}
body.dark-mode .log-item {
background: #2d3748;
}
.log-item pre {
background: #fff;
border: 1px solid #ddd;
}
body.dark-mode .log-item pre {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2,
body.dark-mode h3,
body.dark-mode h4 {
color: #e2e8f0;
}
body.dark-mode p {
color: #a0aec0;
}
body.dark-mode strong {
color: #e2e8f0;
}
body.dark-mode code {
background: #1a202c;
color: #e2e8f0;
padding: 2px 6px;
border-radius: 3px;
}
body.dark-mode small {
color: #718096;
}
.credential-item {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e2e8f0;
}
body.dark-mode .credential-item {
border-bottom-color: #4a5568;
}
.credential-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.credential-label {
font-weight: 600;
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: #495057;
}
body.dark-mode .credential-label {
color: #a0aec0;
}
.credential-value {
font-family: 'Courier New', monospace;
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
word-break: break-all;
font-size: 0.85rem;
border: 1px solid #dee2e6;
}
body.dark-mode .credential-value {
background: #0d1117;
border-color: #4a5568;
color: #e2e8f0;
}
.status-card {
margin-bottom: 2rem;
}
.status-card.online {
background: #d4edda;
}
body.dark-mode .status-card.online {
background: #1a4d2e;
border: 1px solid #48bb78;
}
.status-card.offline {
background: #f8d7da;
}
body.dark-mode .status-card.offline {
background: #4a1a1a;
border: 1px solid #dc3545;
}
.status-card.other {
background: #fff3cd;
}
body.dark-mode .status-card.other {
background: #4a3800;
border: 1px solid #ecc94b;
}
.playlist-stats {
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
body.dark-mode .playlist-stats {
background: #1a202c;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-stats > div > div:first-child {
color: #a0aec0;
}
</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/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
@@ -15,7 +224,7 @@
</div>
<!-- Player Status Overview -->
<div class="card" style="margin-bottom: 2rem; background: {% if player.status == 'online' %}#d4edda{% elif player.status == 'offline' %}#f8d7da{% else %}#fff3cd{% 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;">
Status:
{% if player.status == 'online' %}
@@ -52,6 +261,47 @@
</p>
</div>
<!-- Playlist Overview Card -->
{% if current_playlist %}
<div class="card" style="margin-bottom: 2rem;">
<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;">
Current Playlist: {{ current_playlist.name }}
</h2>
<div class="playlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Total Items</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.contents.count() }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Playlist Version</div>
<div style="font-size: 1.5rem; font-weight: bold;">v{{ current_playlist.version }}</div>
</div>
<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>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.orientation }}</div>
</div>
</div>
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Edit Playlist Content
</a>
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;"
target="_blank">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
View Live Content
</a>
</div>
</div>
{% endif %}
<!-- Three Column Layout -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
@@ -64,33 +314,44 @@
<form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="update_credentials">
<div style="margin-bottom: 1rem;">
<label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Player Name *</label>
<div class="form-group">
<label for="name">Player Name *</label>
<input type="text" id="name" name="name" value="{{ player.name }}"
required minlength="3"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
required minlength="3" class="form-control">
</div>
<div style="margin-bottom: 1rem;">
<label for="location" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Location</label>
<div class="form-group">
<label for="location">Location</label>
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
placeholder="e.g., Main Lobby"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
placeholder="e.g., Main Lobby" class="form-control">
</div>
<div style="margin-bottom: 1rem;">
<label for="orientation" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Orientation</label>
<select id="orientation" name="orientation"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<div class="form-group">
<label for="orientation">Orientation</label>
<select id="orientation" name="orientation" class="form-control">
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
</select>
</div>
<div style="padding: 1rem; background: #f8f9fa; border-radius: 4px; margin-bottom: 1rem;">
<p style="margin: 0; font-size: 0.9rem;"><strong>Hostname:</strong> {{ player.hostname }}</p>
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Auth Code:</strong> <code>{{ player.auth_code }}</code></p>
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Quick Connect:</strong> {{ player.quickconnect_code or 'Not set' }}</p>
<div class="info-box neutral">
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem; color: #495057;">🔑 Player Credentials</h4>
<div class="credential-item">
<span class="credential-label">Hostname</span>
<div class="credential-value">{{ player.hostname }}</div>
</div>
<div class="credential-item">
<span class="credential-label">Auth Code</span>
<div class="credential-value">{{ player.auth_code }}</div>
</div>
<div class="credential-item">
<span class="credential-label">Quick Connect Code (Hashed)</span>
<div class="credential-value" style="font-size: 0.75rem;">{{ player.quickconnect_code or 'Not set' }}</div>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">⚠️ This is the hashed version for security</small>
</div>
</div>
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
@@ -109,10 +370,9 @@
<form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="assign_playlist">
<div style="margin-bottom: 1rem;">
<label for="playlist_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Select Playlist</label>
<select id="playlist_id" name="playlist_id"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<div class="form-group">
<label for="playlist_id">Select Playlist</label>
<select id="playlist_id" name="playlist_id" class="form-control">
<option value="">-- No Playlist (Unassign) --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}"
@@ -124,8 +384,8 @@
</div>
{% if current_playlist %}
<div style="padding: 1rem; background: #d4edda; border-radius: 4px; margin-bottom: 1rem;">
<h4 style="margin: 0 0 0.5rem 0; color: #155724;">Currently Assigned:</h4>
<div class="info-box success">
<h4 style="margin: 0 0 0.5rem 0;">Currently Assigned:</h4>
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></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>
@@ -134,8 +394,8 @@
</p>
</div>
{% else %}
<div style="padding: 1rem; background: #fff3cd; border-radius: 4px; margin-bottom: 1rem;">
<p style="margin: 0; color: #856404; display: flex; align-items: center; gap: 0.5rem;">
<div class="info-box warning">
<p style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
No playlist currently assigned to this player.
</p>

View File

@@ -1,10 +1,234 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ player.name }} - Live Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
font-family: Arial, sans-serif;
}
#player-container {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
#media-display {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
display: none;
}
#media-display.active {
display: block;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
text-align: center;
}
.info-overlay {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
}
.controls {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1000;
transition: opacity 0.3s ease;
}
.controls button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.controls button:hover {
background: rgba(255, 255, 255, 0.3);
}
.no-content {
color: white;
text-align: center;
font-size: 24px;
}
</style>
</head>
<body>
<div id="player-container">
<div class="loading" id="loading">Loading playlist...</div>
<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>
<div class="no-content" id="no-content" style="display: none;">
<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>
</div>
</div>
<div class="info-overlay" id="info-overlay" style="display: none;">
<div id="current-item">Item: -</div>
<div id="playlist-info">Playlist: {{ playlist|length }} items</div>
</div>
<div class="controls">
<button onclick="toggleFullscreen()">🔳 Fullscreen</button>
<button onclick="restartPlaylist()">🔄 Restart</button>
<button onclick="window.close()">✖️ Close</button>
</div>
{% block title %}Player Fullscreen{% endblock %}
{% block content %}
<div class="container">
<h2>Player Fullscreen View</h2>
<p>Fullscreen player view - placeholder</p>
</div>
{% endblock %}
<script>
const playlist = {{ playlist|tojson }};
let currentIndex = 0;
let timer = null;
let inactivityTimer = null;
const imgDisplay = document.getElementById('media-display');
const videoDisplay = document.getElementById('video-display');
const loading = document.getElementById('loading');
const noContent = document.getElementById('no-content');
const infoOverlay = document.getElementById('info-overlay');
const currentItemDiv = document.getElementById('current-item');
const controls = document.querySelector('.controls');
function playNext() {
if (!playlist || playlist.length === 0) {
loading.style.display = 'none';
noContent.style.display = 'block';
return;
}
const item = playlist[currentIndex];
loading.style.display = 'none';
infoOverlay.style.display = 'block';
// Update info
currentItemDiv.textContent = `Item ${currentIndex + 1}/${playlist.length}: ${item.filename}`;
// Hide both displays
imgDisplay.style.display = 'none';
videoDisplay.style.display = 'none';
videoDisplay.pause();
if (item.type === 'video') {
videoDisplay.src = item.url;
videoDisplay.style.display = 'block';
videoDisplay.play();
// When video ends, move to next
videoDisplay.onended = () => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
};
} else {
// Image or PDF
imgDisplay.src = item.url;
imgDisplay.style.display = 'block';
imgDisplay.classList.add('active');
// Clear any existing timer
if (timer) clearTimeout(timer);
// Show for specified duration
timer = setTimeout(() => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
}, (item.duration || 10) * 1000);
}
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function restartPlaylist() {
currentIndex = 0;
if (timer) clearTimeout(timer);
videoDisplay.pause();
playNext();
}
// Auto-hide controls after 5 seconds of inactivity
function resetInactivityTimer() {
// Show controls
controls.style.opacity = '1';
controls.style.pointerEvents = 'auto';
// Clear existing timer
if (inactivityTimer) {
clearTimeout(inactivityTimer);
}
// Set new timer to hide controls after 5 seconds
inactivityTimer = setTimeout(() => {
controls.style.opacity = '0';
controls.style.pointerEvents = 'none';
}, 5000);
}
// Track user activity to show/hide controls
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('mousedown', resetInactivityTimer);
document.addEventListener('keydown', resetInactivityTimer);
document.addEventListener('touchstart', resetInactivityTimer);
// Start playing when page loads
window.addEventListener('load', () => {
playNext();
resetInactivityTimer(); // Start inactivity timer
});
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'f' || e.key === 'F') {
toggleFullscreen();
} else if (e.key === 'r' || e.key === 'R') {
restartPlaylist();
} else if (e.key === 'Escape' && document.fullscreenElement) {
document.exitFullscreen();
}
});
</script>
</body>
</html>

View File

@@ -3,6 +3,129 @@
{% block title %}Players - DigiServer v2{% endblock %}
{% block content %}
<style>
body.dark-mode h1 {
color: #e2e8f0;
}
.players-table {
width: 100%;
border-collapse: collapse;
}
.players-table thead tr {
background: #f8f9fa;
text-align: left;
}
body.dark-mode .players-table thead tr {
background: #1a202c;
}
.players-table th {
padding: 12px;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
color: #495057;
}
body.dark-mode .players-table th {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
.players-table tbody tr {
border-bottom: 1px solid #dee2e6;
}
body.dark-mode .players-table tbody tr {
border-bottom-color: #4a5568;
}
.players-table tbody tr:hover {
background: #f8f9fa;
}
body.dark-mode .players-table tbody tr:hover {
background: #2d3748;
}
.players-table td {
padding: 12px;
color: #2d3748;
}
body.dark-mode .players-table td {
color: #e2e8f0;
}
.players-table td strong {
color: #2d3748;
}
body.dark-mode .players-table td strong {
color: #e2e8f0;
}
.players-table code {
background: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
body.dark-mode .players-table code {
background: #1a202c;
color: #e2e8f0;
}
.status-badge {
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
color: white;
}
.status-badge.online {
background: #28a745;
}
.status-badge.offline {
background: #6c757d;
}
.text-muted {
color: #6c757d;
}
body.dark-mode .text-muted {
color: #718096;
}
.info-box {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
padding: 15px;
border-radius: 5px;
}
body.dark-mode .info-box {
background: #1a365d;
border-color: #2c5282;
color: #90cdf4;
}
.info-box a {
color: #0c5460;
text-decoration: underline;
}
body.dark-mode .info-box a {
color: #90cdf4;
}
</style>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Players</h1>
@@ -11,49 +134,49 @@
{% if players %}
<div class="card">
<table style="width: 100%; border-collapse: collapse;">
<table class="players-table">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Last Seen</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
<tr>
<th>Name</th>
<th>Hostname</th>
<th>Location</th>
<th>Orientation</th>
<th>Status</th>
<th>Last Seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<tr>
<td>
<strong>{{ player.name }}</strong>
</td>
<td style="padding: 12px;">
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{{ player.hostname }}</code>
<td>
<code>{{ player.hostname }}</code>
</td>
<td style="padding: 12px;">
<td>
{{ player.location or '-' }}
</td>
<td style="padding: 12px;">
<td>
{{ player.orientation or 'Landscape' }}
</td>
<td style="padding: 12px;">
<td>
{% set status = player_statuses.get(player.id, {}) %}
{% if status.get('is_online') %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Online</span>
<span class="status-badge online">Online</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Offline</span>
<span class="status-badge offline">Offline</span>
{% endif %}
</td>
<td style="padding: 12px;">
<td>
{% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span style="color: #6c757d;">Never</span>
<span class="text-muted">Never</span>
{% endif %}
</td>
<td style="padding: 12px;">
<td>
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn btn-info btn-sm" title="Manage Player">
⚙️ Manage
@@ -65,8 +188,8 @@
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
No players yet. <a href="{{ url_for('players.add_player') }}" style="color: #0c5460; text-decoration: underline;">Add your first player</a>
<div class="info-box">
No players yet. <a href="{{ url_for('players.add_player') }}">Add your first player</a>
</div>
{% endif %}
</div>

View File

@@ -219,6 +219,92 @@
.duration-input {
width: 80px;
text-align: center;
transition: all 0.3s ease;
}
.duration-input:hover {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.duration-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.save-duration-btn {
transition: all 0.2s ease;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Dark mode support */
body.dark-mode .playlist-section {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
body.dark-mode .section-header {
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table thead {
background: #1a202c;
}
body.dark-mode .playlist-table th {
color: #cbd5e0;
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table td {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .playlist-table tr:hover {
background: #1a202c;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #667eea;
background: #2d3748;
}
body.dark-mode .add-content-form {
background: #1a202c;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
body.dark-mode .empty-state {
color: #718096;
}
body.dark-mode .drag-handle {
color: #718096;
}
</style>
@@ -290,9 +376,9 @@
</thead>
<tbody id="playlist-tbody">
{% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
<tr class="draggable-row" data-content-id="{{ content.id }}">
<td>
<span class="drag-handle">⋮⋮</span>
<span class="drag-handle" draggable="true">⋮⋮</span>
</td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
@@ -304,11 +390,28 @@
{% endif %}
</td>
<td>
<input type="number"
class="form-control duration-input"
value="{{ content.duration }}"
min="1"
onchange="updateDuration({{ content.id }}, this.value)">
<div style="display: flex; align-items: center; gap: 5px;">
<input type="number"
class="form-control duration-input"
id="duration-{{ content.id }}"
value="{{ content._playlist_duration }}"
min="1"
draggable="false"
onclick="event.stopPropagation()"
onmousedown="event.stopPropagation()"
oninput="markDurationChanged({{ content.id }})"
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})"
style="width: 60px; padding: 5px 8px;">
<button type="button"
class="btn btn-success btn-sm save-duration-btn"
id="save-btn-{{ content.id }}"
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
onmousedown="event.stopPropagation()"
style="display: none; padding: 5px 10px; font-size: 12px; cursor: pointer;"
title="Save duration (or press Enter)">
💾
</button>
</div>
</td>
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
<td>
@@ -335,7 +438,7 @@
</div>
<!-- Add Content Section -->
{% if available_files %}
{% if available_content %}
<div class="playlist-section">
<div class="section-header">
<h2> Add Existing Content</h2>
@@ -344,11 +447,11 @@
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
class="add-content-form">
<div class="form-group">
<label for="filename">Select File:</label>
<select name="filename" id="filename" class="form-control" required>
<option value="" disabled selected>Choose a file...</option>
{% for filename in available_files %}
<option value="{{ filename }}">{{ filename }}</option>
<label for="content_id">Select Content:</label>
<select name="content_id" id="content_id" class="form-control" required>
<option value="" disabled selected>Choose content...</option>
{% for content in available_content %}
<option value="{{ content.id }}">{{ content.filename }} ({{ content.content_type }})</option>
{% endfor %}
</select>
</div>
@@ -380,20 +483,41 @@ document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('playlist-tbody');
if (!tbody) return;
const rows = tbody.querySelectorAll('.draggable-row');
// Set up drag handles
const dragHandles = tbody.querySelectorAll('.drag-handle');
dragHandles.forEach(handle => {
handle.addEventListener('dragstart', handleDragStart);
});
// Set up drop zones on rows
const rows = tbody.querySelectorAll('.draggable-row');
rows.forEach(row => {
row.addEventListener('dragstart', handleDragStart);
row.addEventListener('dragover', handleDragOver);
row.addEventListener('drop', handleDrop);
row.addEventListener('dragend', handleDragEnd);
});
// Prevent dragging from inputs and buttons
const inputs = document.querySelectorAll('.duration-input, button');
inputs.forEach(input => {
input.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
input.addEventListener('click', (e) => {
e.stopPropagation();
});
});
});
function handleDragStart(e) {
draggedElement = this;
this.classList.add('dragging');
// Get the parent row
const row = e.target.closest('.draggable-row');
if (!row) return;
draggedElement = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', row.innerHTML);
}
function handleDragOver(e) {
@@ -464,7 +588,41 @@ function saveOrder() {
});
}
function updateDuration(contentId, duration) {
function markDurationChanged(contentId) {
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const input = document.getElementById(`duration-${contentId}`);
// Show save button if value changed
if (input.value !== input.defaultValue) {
saveBtn.style.display = 'inline-block';
input.style.borderColor = '#ffc107';
} else {
saveBtn.style.display = 'none';
input.style.borderColor = '';
}
}
function saveDuration(contentId) {
const inputElement = document.getElementById(`duration-${contentId}`);
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const duration = parseInt(inputElement.value);
// Validate duration
if (duration < 1) {
alert('Duration must be at least 1 second');
inputElement.value = inputElement.defaultValue;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
return;
}
const originalValue = inputElement.defaultValue;
// Visual feedback
inputElement.disabled = true;
saveBtn.disabled = true;
saveBtn.textContent = '⏳';
const formData = new FormData();
formData.append('duration', duration);
@@ -476,15 +634,65 @@ function updateDuration(contentId, duration) {
.then(data => {
if (data.success) {
console.log('Duration updated successfully');
// Update total duration
location.reload();
inputElement.style.borderColor = '#28a745';
inputElement.defaultValue = duration;
saveBtn.textContent = '✓';
// Update total duration display
updateTotalDuration();
setTimeout(() => {
inputElement.style.borderColor = '';
inputElement.disabled = false;
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
} else {
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration: ' + data.message);
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
}
})
.catch(error => {
console.error('Error:', error);
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration');
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
});
}
function updateTotalDuration() {
const durationInputs = document.querySelectorAll('.duration-input');
let total = 0;
durationInputs.forEach(input => {
total += parseInt(input.value) || 0;
});
const statValues = document.querySelectorAll('.stat-value');
statValues.forEach((element, index) => {
const label = element.parentElement.querySelector('.stat-label');
if (label && label.textContent.includes('Total Duration')) {
element.textContent = total + 's';
}
});
}
</script>