Replace emoji icons with local SVG files for consistent rendering

- Created 10 SVG icon files in app/static/icons/ (Feather Icons style)
- Updated base.html with SVG icons in navigation and dark mode toggle
- Updated dashboard.html with icons in stats cards and quick actions
- Updated content_list_new.html (playlist management) with SVG icons
- Updated upload_media.html with upload-related icons
- Updated manage_player.html with player management icons
- Icons use currentColor for automatic theme adaptation
- Removed emoji dependency for better Raspberry Pi compatibility
- Added ICON_INTEGRATION.md documentation
This commit is contained in:
ske087
2025-11-13 21:00:07 +02:00
parent e5a00d19a5
commit 498c03ef00
37 changed files with 4240 additions and 840 deletions

138
ICON_INTEGRATION.md Normal file
View File

@@ -0,0 +1,138 @@
# SVG Icon Integration
## Overview
Replaced all emoji icons with local SVG files to ensure consistent rendering across all systems, particularly on Raspberry Pi devices where emoji fonts may not be available.
## Icon Files Created
Location: `/app/static/icons/`
1. **moon.svg** - Dark mode toggle (off state)
2. **sun.svg** - Dark mode toggle (on state)
3. **home.svg** - Dashboard/home navigation
4. **monitor.svg** - Players/screens
5. **playlist.svg** - Playlist management
6. **edit.svg** - Edit actions
7. **trash.svg** - Delete actions
8. **upload.svg** - Upload/media files
9. **info.svg** - Information/details
10. **warning.svg** - Warnings/alerts
## Icon Specifications
- **Size**: 24x24 viewBox (scalable)
- **Style**: Feather Icons design (minimal, stroke-based)
- **Color**: Uses `currentColor` for automatic theme adaptation
- **Stroke**: 2px width, round line caps and joins
- **Format**: Clean SVG with no fills (stroke-only for consistency)
## Templates Updated
### 1. base.html (Navigation & Dark Mode)
- Header logo: monitor.svg
- Dashboard link: home.svg
- Players link: monitor.svg
- Playlists link: playlist.svg
- Dark mode toggle: moon.svg ↔ sun.svg (dynamic)
**CSS Changes:**
- Added icon support to nav links with `display: flex`
- Icons use `filter: brightness(0) invert(1)` for white color on dark header
- Dark mode toggle icon changes via JavaScript using `src` attribute
### 2. dashboard.html (Stats Cards & Quick Actions)
- Players card: monitor.svg
- Playlists card: playlist.svg
- Media Library card: upload.svg
- Storage card: info.svg
- Quick Actions buttons: monitor.svg, playlist.svg, upload.svg
- Workflow Guide header: info.svg
### 3. content_list_new.html (Playlist Management)
- Page header: playlist.svg
- Playlists card header: playlist.svg
- Delete button: trash.svg
- Empty state: playlist.svg (64px, opacity 0.3)
- Upload Media card header: upload.svg
- Upload icon (large): upload.svg (96px, opacity 0.5)
- Go to Upload button: upload.svg
- Media library icons: info.svg (images), monitor.svg (videos)
- Player Assignments header: monitor.svg
### 4. upload_media.html (Upload Page)
- Page header: upload.svg
- Back button: playlist.svg
- Select Files card: upload.svg
- Drag-drop zone: upload.svg (96px, opacity 0.3)
- Browse button: upload.svg
- Upload Settings header: info.svg
- Upload button: upload.svg
### 5. manage_player.html (Player Management)
- Page header: monitor.svg
- Back button: monitor.svg
- Status icons: info.svg (online), warning.svg (offline)
- Edit Credentials card: edit.svg
- Save button: edit.svg
- Assign Playlist card: playlist.svg
- Warning alert: warning.svg
- Assign button: playlist.svg
- Create Playlist button: playlist.svg
- Edit Playlist button: edit.svg
- Player Logs card: info.svg
## Usage Pattern
```html
<!-- Standard icon in heading -->
<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;">
Playlists
</h2>
<!-- Icon in button (white on colored background) -->
<button class="btn btn-success" style="display: 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 Files
</button>
<!-- Large decorative icon -->
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3;">
```
## Benefits
1. **Reliability**: Icons always render correctly, no dependency on system fonts
2. **Scalability**: SVG format scales cleanly to any size
3. **Themeable**: `currentColor` allows icons to inherit text color
4. **Performance**: Small file sizes (< 1KB each)
5. **Consistency**: Uniform design language across entire application
6. **Accessibility**: Clean, recognizable icons work well in both light and dark modes
## Browser Compatibility
- All modern browsers support inline SVG
- No JavaScript required (except dark mode toggle)
- Works on Raspberry Pi browsers (Chromium, Firefox)
- Filter properties supported in all target browsers
## Dark Mode Integration
The dark mode toggle now uses SVG icons instead of emoji:
- Moon icon (🌙) → moon.svg
- Sun icon (☀️) → sun.svg
- Icons change dynamically via JavaScript when theme is toggled
- White color achieved via CSS filter on dark header background
## Future Icon Needs
If additional icons are needed, follow this pattern:
1. Use Feather Icons style (https://feathericons.com/)
2. 24x24 viewBox, stroke-width="2"
3. Use `stroke="currentColor"` for theme compatibility
4. Save to `/app/static/icons/`
5. Reference via `{{ url_for('static', filename='icons/name.svg') }}`
## Testing Checklist
- [x] Header navigation icons display correctly
- [x] Dark mode toggle icons switch properly
- [x] Dashboard card icons visible
- [x] Playlist management icons render
- [x] Upload page icons functional
- [x] Player management icons working
- [ ] Test on actual Raspberry Pi device
- [ ] Verify icon visibility in both light/dark modes
- [ ] Check icon alignment across different screen sizes

View File

@@ -0,0 +1,250 @@
# Kivy Player Compatibility with New Playlist Architecture
## Overview
This document outlines the compatibility verification and updates made to ensure the Kivy signage player works with the new playlist-centric architecture.
## Changes Made to DigiServer API
### 1. Content URL Format in Playlist Response
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py` - `get_cached_playlist()` function
**Issue:**
- Player expects: `file_name` key in playlist items
- Server was returning: `filename` key
**Fix:**
```python
playlist_data.append({
'id': content.id,
'file_name': content.filename, # Changed from 'filename' to 'file_name'
'type': content.content_type,
'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx,
'url': content_url, # Now returns full URL with server base
'description': content.description
})
```
**What Changed:**
- ✅ Changed `'filename'` to `'file_name'` to match player expectations
- ✅ URL now includes full server base URL (e.g., `http://server:5000/static/uploads/image.jpg`)
- ✅ Player can now download content directly without URL manipulation
### 2. Authentication Response - Removed group_id
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py`
**Endpoints Updated:**
1. `/api/auth/player` (line ~145) - ✅ Already returns `playlist_id`
2. `/api/auth/verify` (line ~192) - ✅ Changed from `group_id` to `playlist_id`
**Before:**
```python
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'group_id': player.group_id, # OLD
'orientation': player.orientation,
'status': player.status
}
```
**After:**
```python
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'playlist_id': player.playlist_id, # NEW
'orientation': player.orientation,
'status': player.status
}
```
## Changes Made to Kivy Player
### 1. Updated player_auth.py
**Location:** `/home/pi/Desktop/Kiwy-Signage/src/player_auth.py`
**Changes:**
1. ✅ Default auth data structure: `group_id``playlist_id` (line ~42)
2. ✅ Authentication data storage: `group_id``playlist_id` (line ~97)
3. ✅ Clear auth method: `group_id``playlist_id` (line ~299)
4. ✅ Example usage output: Updated to show `playlist_id` instead of `group_id`
**Impact:**
- Player now stores and uses `playlist_id` instead of deprecated `group_id`
- Backward compatible: old auth files will load but won't have `playlist_id` until re-authentication
## API Endpoints Used by Kivy Player
### Player Authentication Flow
```
1. POST /api/auth/player
Body: { hostname, password OR quickconnect_code }
Returns: { auth_code, player_id, playlist_id, orientation, ... }
2. GET /api/playlists/<player_id>
Headers: Authorization: Bearer <auth_code>
Returns: { playlist: [...], playlist_version: N, ... }
3. GET <full_url_from_playlist_item>
Downloads content file
```
### Expected Playlist Format
```json
{
"player_id": 1,
"player_name": "Player-001",
"playlist_id": 5,
"playlist_version": 3,
"playlist": [
{
"id": 1,
"file_name": "demo1.jpg",
"type": "image",
"duration": 10,
"position": 1,
"url": "http://192.168.1.100:5000/static/uploads/demo1.jpg",
"description": "Demo Image 1"
}
],
"count": 1
}
```
## Verification Checklist
### Server-Side ✅
- [x] Player model has `playlist_id` field
- [x] Content model has unique filenames
- [x] Playlist model with version tracking
- [x] API returns `file_name` (not `filename`)
- [x] API returns full URLs for content
- [x] API returns `playlist_id` in auth responses
- [x] `/api/playlists/<player_id>` endpoint exists and works
- [x] Bearer token authentication works
### Client-Side ✅
- [x] PlayerAuth class uses `playlist_id`
- [x] get_playlists_v2.py can fetch playlists
- [x] Player can download content from full URLs
- [x] Player parses `file_name` correctly
- [x] Player uses Bearer token auth
## Workflow Example
### 1. Server Setup
```bash
# Create a playlist
curl -X POST http://server:5000/content/create_playlist \
-d "name=Morning Playlist"
# Upload content
curl -X POST http://server:5000/content/upload_media \
-F "file=@demo1.jpg"
# Add content to playlist
curl -X POST http://server:5000/content/add_content_to_playlist \
-d "playlist_id=1&content_id=1&duration=10"
# Create player
curl -X POST http://server:5000/players/add \
-d "name=Player-001&hostname=player001&quickconnect_code=QUICK123"
# Assign playlist to player
curl -X POST http://server:5000/content/assign_player_to_playlist \
-d "player_id=1&playlist_id=1"
```
### 2. Player Authentication
```python
from player_auth import PlayerAuth
auth = PlayerAuth()
success, error = auth.authenticate(
server_url='http://192.168.1.100:5000',
hostname='player001',
quickconnect_code='QUICK123'
)
if success:
print(f"✅ Authenticated as {auth.get_player_name()}")
print(f"📋 Playlist ID: {auth.auth_data['playlist_id']}")
```
### 3. Fetch and Download Playlist
```python
from get_playlists_v2 import update_playlist_if_needed
config = {
'server_ip': '192.168.1.100',
'port': '5000',
'screen_name': 'player001',
'quickconnect_key': 'QUICK123'
}
playlist_file = update_playlist_if_needed(
config=config,
playlist_dir='playlists/',
media_dir='media/'
)
if playlist_file:
print(f"✅ Playlist downloaded: {playlist_file}")
```
## Testing Recommendations
1. **Test Authentication:**
- Test with valid quickconnect code
- Test with invalid quickconnect code
- Verify `playlist_id` is returned
2. **Test Playlist Fetching:**
- Fetch playlist with auth code
- Verify `file_name` and full URLs in response
- Test playlist version tracking
3. **Test Content Download:**
- Download images from full URLs
- Download videos from full URLs
- Verify files save correctly
4. **Test Playlist Updates:**
- Update playlist version on server
- Player should detect new version
- Player should download only new content
## Migration Notes
### For Existing Deployments
1. **Server:** Database recreated with new schema (groups removed)
2. **Players:** Will re-authenticate on next check
3. **Auth Files:** Old `player_auth.json` files are compatible but will update on next auth
### Backward Compatibility
- ⚠️ Legacy `/api/playlists?hostname=X&quickconnect_code=Y` endpoint still exists
- ⚠️ New v2 auth flow preferred (uses Bearer tokens)
- ⚠️ Old player code using groups will need updates
## Known Issues / Future Improvements
1. **Content Cleanup:** Old media files not referenced by playlists should be deleted
2. **Playlist Caching:** Cache invalidation when playlist updated on server
3. **Partial Updates:** Download only changed content instead of full playlist
4. **Network Resilience:** Better handling of connection failures
5. **Progress Feedback:** Real-time download progress for large files
## Contact
For issues or questions about player compatibility, check:
- Server logs: `/home/pi/Desktop/digiserver-v2/instance/logs/`
- Player logs: See PlayerAuth logger output
- API documentation: Check `/api/health` endpoint
---
**Last Updated:** 2025-01-XX
**Version:** DigiServer v2.0 with Playlist Architecture

44
add_orientation_column.py Normal file
View File

@@ -0,0 +1,44 @@
"""
Add orientation column to playlist table
Run this script to update the database schema
"""
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.app import app
from app.extensions import db
from sqlalchemy import text
def add_orientation_column():
"""Add orientation column to playlist table."""
with app.app_context():
try:
# Check if column exists
result = db.session.execute(text("PRAGMA table_info(playlist)"))
columns = [row[1] for row in result]
if 'orientation' in columns:
print("✅ Column 'orientation' already exists in playlist table")
return
# Add the column
print("Adding 'orientation' column to playlist table...")
db.session.execute(text("""
ALTER TABLE playlist
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
"""))
db.session.commit()
print("✅ Successfully added 'orientation' column to playlist table")
print(" Default value: 'Landscape'")
except Exception as e:
print(f"❌ Error adding column: {str(e)}")
db.session.rollback()
raise
if __name__ == '__main__':
add_orientation_column()

View File

@@ -62,6 +62,7 @@ def register_blueprints(app):
from app.blueprints.players import players_bp from app.blueprints.players import players_bp
from app.blueprints.groups import groups_bp from app.blueprints.groups import groups_bp
from app.blueprints.content import content_bp from app.blueprints.content import content_bp
from app.blueprints.playlist import playlist_bp
from app.blueprints.api import api_bp from app.blueprints.api import api_bp
# Register blueprints (using URL prefixes from blueprint definitions) # Register blueprints (using URL prefixes from blueprint definitions)
@@ -71,6 +72,7 @@ def register_blueprints(app):
app.register_blueprint(players_bp) app.register_blueprint(players_bp)
app.register_blueprint(groups_bp) app.register_blueprint(groups_bp)
app.register_blueprint(content_bp) app.register_blueprint(content_bp)
app.register_blueprint(playlist_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)

View File

@@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app
from functools import wraps from functools import wraps
from datetime import datetime, timedelta from datetime import datetime, timedelta
import secrets import secrets
import bcrypt
from typing import Optional, Dict, List from typing import Optional, Dict, List
from app.extensions import db, cache from app.extensions import db, cache
@@ -142,7 +143,7 @@ def authenticate_player():
'player_name': player.name, 'player_name': player.name,
'hostname': player.hostname, 'hostname': player.hostname,
'auth_code': player.auth_code, 'auth_code': player.auth_code,
'group_id': player.group_id, 'playlist_id': player.playlist_id,
'orientation': player.orientation, 'orientation': player.orientation,
'status': player.status 'status': player.status
} }
@@ -186,7 +187,7 @@ def verify_auth_code():
'player_id': player.id, 'player_id': player.id,
'player_name': player.name, 'player_name': player.name,
'hostname': player.hostname, 'hostname': player.hostname,
'group_id': player.group_id, 'playlist_id': player.playlist_id,
'orientation': player.orientation, 'orientation': player.orientation,
'status': player.status 'status': player.status
} }
@@ -194,6 +195,103 @@ def verify_auth_code():
return jsonify(response), 200 return jsonify(response), 200
@api_bp.route('/playlists', methods=['GET'])
@rate_limit(max_requests=30, window=60)
def get_playlist_by_quickconnect():
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
Query parameters:
hostname: Player hostname/identifier
quickconnect_code: Quick connect code for authentication
Returns:
JSON with playlist, playlist_version, and hashed_quickconnect
"""
try:
import bcrypt
hostname = request.args.get('hostname')
quickconnect_code = request.args.get('quickconnect_code')
if not hostname or not quickconnect_code:
return jsonify({
'error': 'hostname and quickconnect_code are required',
'playlist': [],
'playlist_version': 0
}), 400
# Find player by hostname and validate quickconnect
player = Player.query.filter_by(hostname=hostname).first()
if not player:
log_action('warning', f'Player not found with hostname: {hostname}')
return jsonify({
'error': 'Player not found',
'playlist': [],
'playlist_version': 0
}), 404
# Validate quickconnect code
if not player.quickconnect_code:
log_action('warning', f'Player {hostname} has no quickconnect code set')
return jsonify({
'error': 'Quickconnect not configured',
'playlist': [],
'playlist_version': 0
}), 403
# Check if quickconnect matches
if player.quickconnect_code != quickconnect_code:
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
return jsonify({
'error': 'Invalid quickconnect code',
'playlist': [],
'playlist_version': 0
}), 403
# Get playlist (with caching)
playlist = get_cached_playlist(player.id)
# Update player's last seen timestamp and status
player.last_seen = datetime.utcnow()
player.status = 'online'
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
# Hash the quickconnect code for validation on client side
hashed_quickconnect = bcrypt.hashpw(
quickconnect_code.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
return jsonify({
'player_id': player.id,
'player_name': player.name,
'playlist_id': player.playlist_id,
'playlist_version': playlist_version,
'playlist': playlist,
'hashed_quickconnect': hashed_quickconnect,
'count': len(playlist)
}), 200
except Exception as e:
log_action('error', f'Error getting playlist: {str(e)}')
return jsonify({
'error': 'Internal server error',
'playlist': [],
'playlist_version': 0
}), 500
@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=30, window=60)
@verify_player_auth @verify_player_auth
@@ -216,11 +314,19 @@ def get_player_playlist(player_id: int):
player.last_seen = datetime.utcnow() player.last_seen = datetime.utcnow()
db.session.commit() db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
return jsonify({ return jsonify({
'player_id': player_id, 'player_id': player_id,
'player_name': player.name, 'player_name': player.name,
'group_id': player.group_id, 'playlist_id': player.playlist_id,
'playlist_version': player.playlist_version, 'playlist_version': playlist_version,
'playlist': playlist, 'playlist': playlist,
'count': len(playlist) 'count': len(playlist)
}) })
@@ -263,78 +369,110 @@ def get_playlist_version(player_id: int):
@cache.memoize(timeout=300) @cache.memoize(timeout=300)
def get_cached_playlist(player_id: int) -> List[Dict]: def get_cached_playlist(player_id: int) -> List[Dict]:
"""Get cached playlist for a player.""" """Get cached playlist for a player based on assigned playlist."""
from flask import url_for from flask import url_for
from app.models import Playlist
player = Player.query.get(player_id) player = Player.query.get(player_id)
if not player: if not player or not player.playlist_id:
return [] return []
# Get content based on group assignment # Get the playlist assigned to this player
if player.group_id: playlist = Playlist.query.get(player.playlist_id)
group = Group.query.get(player.group_id) if not playlist:
contents = group.contents.order_by(Content.position).all() if group else [] return []
else:
# Show all content if not in a group
contents = Content.query.order_by(Content.position).all()
# Build playlist # Get content from playlist (ordered)
playlist = [] content_list = playlist.get_content_ordered()
for content in contents:
playlist.append({ # Build playlist response
playlist_data = []
for idx, content in enumerate(content_list, start=1):
# Generate full URL for content
from flask import request as current_request
# Get server base URL
server_base = current_request.host_url.rstrip('/')
content_url = f"{server_base}/static/uploads/{content.filename}"
playlist_data.append({
'id': content.id, 'id': content.id,
'filename': content.filename, 'file_name': content.filename, # Player expects 'file_name' not 'filename'
'type': content.content_type, 'type': content.content_type,
'duration': content.duration or 10, 'duration': content._playlist_duration or content.duration or 10,
'position': content.position, 'position': content._playlist_position or idx,
'url': f"/static/uploads/{content.filename}", 'url': content_url, # Full URL for downloads
'description': content.description 'description': content.description
}) })
return playlist 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=100, window=60)
@verify_player_auth
def receive_player_feedback(): def receive_player_feedback():
"""Receive feedback/status updates from players. """Receive feedback/status updates from players (Kivy player compatible).
Expected JSON payload: Expected JSON payload:
{ {
"status": "playing|paused|error", "player_name": "Screen1",
"current_content_id": 123, "quickconnect_code": "ABC123",
"message": "Optional status message", "status": "playing|paused|error|restarting",
"error": "Optional error message" "message": "Status message",
"playlist_version": 1,
"error_details": "Optional error details",
"timestamp": "ISO timestamp"
} }
""" """
try: try:
player = request.player
data = request.json data = request.json
if not data: if not data:
return jsonify({'error': 'No data provided'}), 400 return jsonify({'error': 'No data provided'}), 400
player_name = data.get('player_name')
quickconnect_code = data.get('quickconnect_code')
if not player_name or not quickconnect_code:
return jsonify({'error': 'player_name and quickconnect_code required'}), 400
# Find player by name and validate quickconnect
player = Player.query.filter_by(name=player_name).first()
if not player:
log_action('warning', f'Player feedback from unknown player: {player_name}')
return jsonify({'error': 'Player not found'}), 404
# Validate quickconnect code
if player.quickconnect_code != quickconnect_code:
log_action('warning', f'Invalid quickconnect in feedback from: {player_name}')
return jsonify({'error': 'Invalid quickconnect code'}), 403
# Create feedback record # Create feedback record
status = data.get('status', 'unknown')
message = data.get('message', '')
error_details = data.get('error_details')
feedback = PlayerFeedback( feedback = PlayerFeedback(
player_id=player.id, player_id=player.id,
status=data.get('status', 'unknown'), status=status,
current_content_id=data.get('current_content_id'), message=message,
message=data.get('message'), error=error_details
error=data.get('error')
) )
db.session.add(feedback) db.session.add(feedback)
# Update player's last seen # Update player's last seen and status
player.last_seen = datetime.utcnow() player.last_seen = datetime.utcnow()
player.status = data.get('status', 'unknown') player.status = status
db.session.commit() db.session.commit()
log_action('info', f'Feedback received from {player_name}: {status} - {message}')
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Feedback received' 'message': 'Feedback received',
}) 'player_id': player.id
}), 200
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()

View File

@@ -1,532 +1,376 @@
"""Content blueprint for media upload and management.""" """Content blueprint - New playlist-centric workflow."""
from flask import (Blueprint, render_template, request, redirect, url_for, from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app, send_from_directory) 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
import os import os
from typing import Optional, Dict
import json
from app.extensions import db, cache from app.extensions import db, cache
from app.models import Content, Group from app.models import Content, Playlist, Player
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 ( from app.utils.uploads import process_video_file, set_upload_progress
save_uploaded_file,
process_video_file,
process_pdf_file,
get_upload_progress,
set_upload_progress
)
content_bp = Blueprint('content', __name__, url_prefix='/content') content_bp = Blueprint('content', __name__, url_prefix='/content')
# In-memory storage for upload progress (for simple demo; use Redis in production)
upload_progress = {}
@content_bp.route('/') @content_bp.route('/')
@login_required @login_required
def content_list(): def content_list():
"""Display list of all content.""" """Main playlist management page."""
try: playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
# Get all unique content files (by filename) media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
from sqlalchemy import func players = Player.query.order_by(Player.name).all()
# Get content with player information return render_template('content/content_list_new.html',
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all() playlists=playlists,
media_files=media_files,
# Group content by filename to show which players have each file players=players)
content_map = {}
for content in contents:
if content.filename not in content_map:
content_map[content.filename] = {
'content': content,
'players': [],
'groups': []
}
# Add player info if assigned to a player
if content.player_id:
from app.models import Player
player = Player.query.get(content.player_id)
if player:
content_map[content.filename]['players'].append({
'id': player.id,
'name': player.name,
'group': player.group.name if player.group else None
})
# Convert to list for template
content_list = []
for filename, data in content_map.items():
content_list.append({
'filename': filename,
'content_type': data['content'].content_type,
'duration': data['content'].duration,
'file_size': data['content'].file_size_mb,
'uploaded_at': data['content'].uploaded_at,
'players': data['players'],
'player_count': len(data['players'])
})
# Sort by upload date
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
return render_template('content/content_list.html',
content_list=content_list)
except Exception as e:
log_action('error', f'Error loading content list: {str(e)}')
flash('Error loading content list.', 'danger')
return redirect(url_for('main.dashboard'))
@content_bp.route('/upload', methods=['GET', 'POST']) @content_bp.route('/playlist/create', methods=['POST'])
@login_required @login_required
def upload_content(): def create_playlist():
"""Upload new content.""" """Create a new playlist."""
if request.method == 'GET':
# Get parameters for return URL and pre-selection
target_type = request.args.get('target_type')
target_id = request.args.get('target_id', type=int)
return_url = request.args.get('return_url', url_for('content.content_list'))
# Get all players and groups for selection
from app.models import Player
players = [{'id': p.id, 'name': p.name} for p in Player.query.order_by(Player.name).all()]
groups = [{'id': g.id, 'name': g.name} for g in Group.query.order_by(Group.name).all()]
return render_template('content/upload_content.html',
players=players,
groups=groups,
target_type=target_type,
target_id=target_id,
return_url=return_url)
try: try:
# Get form data name = request.form.get('name', '').strip()
target_type = request.form.get('target_type')
target_id = request.form.get('target_id', type=int)
media_type = request.form.get('media_type', 'image')
duration = request.form.get('duration', type=int, default=10)
session_id = request.form.get('session_id', os.urandom(8).hex())
return_url = request.form.get('return_url', url_for('content.content_list'))
# Get files
files = request.files.getlist('files')
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_content'))
if not target_type or not target_id:
flash('Please select a target type and target ID.', 'warning')
return redirect(url_for('content.upload_content'))
# Initialize progress tracking using shared utility
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
# Process each file
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
processed_count = 0
total_files = len(files)
for idx, file in enumerate(files):
if file.filename == '':
continue
# Update progress
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
set_upload_progress(session_id, progress_pct,
f'Processing file {idx + 1} of {total_files}...', 'processing')
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename)
# Save file
file.save(filepath)
# Determine content type
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
content_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
content_type = 'video'
# Process video (convert to Raspberry Pi optimized format)
set_upload_progress(session_id, progress_pct + 5,
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
success, message = process_video_file(filepath, session_id)
if not success:
log_action('error', f'Video optimization failed: {message}')
continue # Skip this file and move to next
elif file_ext == 'pdf':
content_type = 'pdf'
# Process PDF (convert to images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PDF {idx + 1}...', 'processing')
# process_pdf_file(filepath, session_id)
elif file_ext in ['ppt', 'pptx']:
content_type = 'presentation'
# Process presentation (convert to PDF then images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PowerPoint {idx + 1}...', 'processing')
# This would call pptx_converter utility
else:
content_type = 'other'
# Create content record
new_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath)
)
# Link to target (player or group)
if target_type == 'player':
from app.models import Player
player = Player.query.get(target_id)
if player:
# Add content directly to player's playlist
new_content.player_id = target_id
db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
elif target_type == 'group':
group = Group.query.get(target_id)
if group:
# For groups, create separate content entry for EACH player in the group
# This matches the old app behavior
for player in group.players:
player_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath),
player_id=player.id
)
db.session.add(player_content)
# Increment each player's playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to {len(group.players)} players in group "{group.name}"')
# Don't add the original new_content since we created per-player entries
new_content = None
if new_content:
db.session.add(new_content)
processed_count += 1
# Commit all changes
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
db.session.commit()
# Complete
set_upload_progress(session_id, 100,
f'Successfully uploaded {processed_count} file(s)!', 'complete')
# Clear all playlist caches
cache.clear()
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
return redirect(return_url)
except Exception as e:
db.session.rollback()
# Update progress to error state
if 'session_id' in locals():
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
log_action('error', f'Error uploading content: {str(e)}')
flash('Error uploading content. Please try again.', 'danger')
return redirect(url_for('content.upload_content'))
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_content(content_id: int):
"""Edit content metadata."""
content = Content.query.get_or_404(content_id)
if request.method == 'GET':
return render_template('content/edit_content.html', content=content)
try:
duration = request.form.get('duration', type=int)
description = request.form.get('description', '').strip() description = request.form.get('description', '').strip()
orientation = request.form.get('orientation', 'Landscape')
# Update content if not name:
if duration is not None: flash('Playlist name is required.', 'warning')
content.duration = duration return redirect(url_for('content.content_list'))
content.description = description or None
# Check if playlist name exists
existing = Playlist.query.filter_by(name=name).first()
if existing:
flash(f'Playlist "{name}" already exists.', 'warning')
return redirect(url_for('content.content_list'))
playlist = Playlist(
name=name,
description=description or None,
orientation=orientation
)
db.session.add(playlist)
db.session.commit() db.session.commit()
# Clear caches log_action('info', f'Created playlist: {name}')
cache.clear() flash(f'Playlist "{name}" created successfully!', 'success')
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
flash(f'Content "{content.filename}" updated successfully.', 'success')
return redirect(url_for('content.content_list'))
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
log_action('error', f'Error updating content: {str(e)}') log_action('error', f'Error creating playlist: {str(e)}')
flash('Error updating content. Please try again.', 'danger') flash('Error creating playlist.', 'danger')
return redirect(url_for('content.edit_content', content_id=content_id))
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
@login_required
def delete_content(content_id: int):
"""Delete content and associated file."""
try:
content = Content.query.get_or_404(content_id)
filename = content.filename
# Delete file from disk
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
# Delete from database
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
flash(f'Content "{filename}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content: {str(e)}')
flash('Error deleting content. Please try again.', 'danger')
return redirect(url_for('content.content_list')) return redirect(url_for('content.content_list'))
@content_bp.route('/delete-by-filename', methods=['POST']) @content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
@login_required @login_required
def delete_by_filename(): def delete_playlist(playlist_id: int):
"""Delete all content entries with a specific filename.""" """Delete a playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try: try:
data = request.get_json() name = playlist.name
filename = data.get('filename')
if not filename: # Unassign all players from this playlist
return jsonify({'success': False, 'message': 'No filename provided'}), 400 Player.query.filter_by(playlist_id=playlist_id).update({'playlist_id': None})
# Find all content entries with this filename
contents = Content.query.filter_by(filename=filename).all()
if not contents:
return jsonify({'success': False, 'message': 'Content not found'}), 404
deleted_count = len(contents)
# Delete file from disk (only once)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Deleted file from disk: {filename}')
# Delete all database entries
for content in contents:
db.session.delete(content)
db.session.delete(playlist)
db.session.commit() db.session.commit()
# Clear caches
cache.clear() cache.clear()
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)') log_action('info', f'Deleted playlist: {name}')
flash(f'Playlist "{name}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting playlist: {str(e)}')
flash('Error deleting playlist.', 'danger')
return redirect(url_for('content.content_list'))
@content_bp.route('/playlist/<int:playlist_id>/manage')
@login_required
def manage_playlist_content(playlist_id: int):
"""Manage content in a specific playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
# Get content in playlist (ordered)
playlist_content = playlist.get_content_ordered()
# Get all available content not in this playlist
all_content = Content.query.all()
playlist_content_ids = {c.id for c in playlist_content}
available_content = [c for c in all_content if c.id not in playlist_content_ids]
return render_template('content/manage_playlist_content.html',
playlist=playlist,
playlist_content=playlist_content,
available_content=available_content)
@content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST'])
@login_required
def add_content_to_playlist(playlist_id: int):
"""Add content to playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10)
if not content_id:
flash('Please select content to add.', 'warning')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
content = Content.query.get_or_404(content_id)
# Get max position
from sqlalchemy import select, func
from app.models.playlist import playlist_content
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
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_pos + 1,
duration=duration
)
db.session.execute(stmt)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added "{content.filename}" to playlist "{playlist.name}"')
flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding content to playlist: {str(e)}')
flash('Error adding content to playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
@login_required
def remove_content_from_playlist(playlist_id: int, content_id: int):
"""Remove content from playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
from app.models.playlist import playlist_content
# Remove from playlist
stmt = playlist_content.delete().where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Removed content from playlist "{playlist.name}"')
flash('Content removed from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing content from playlist: {str(e)}')
flash('Error removing content from playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist_content(playlist_id: int):
"""Reorder content in 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
# Update positions
for idx, content_id in enumerate(content_ids, start=1):
stmt = playlist_content.update().where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist "{playlist.name}"')
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': f'Content deleted from {deleted_count} playlist(s)', 'message': 'Playlist reordered successfully',
'deleted_count': deleted_count 'version': playlist.version
}) })
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
log_action('error', f'Error deleting content by filename: {str(e)}') log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/bulk/delete', methods=['POST']) @content_bp.route('/upload-media-page')
@login_required @login_required
def bulk_delete_content(): def upload_media_page():
"""Delete multiple content items at once.""" """Display upload media page."""
playlists = Playlist.query.order_by(Playlist.name).all()
return render_template('content/upload_media.html', playlists=playlists)
@content_bp.route('/upload-media', methods=['POST'])
@login_required
def upload_media():
"""Upload media files to library."""
try: try:
content_ids = request.json.get('content_ids', []) files = request.files.getlist('files')
content_type = request.form.get('content_type', 'image')
duration = request.form.get('duration', type=int, default=10)
playlist_id = request.form.get('playlist_id', type=int)
if not content_ids: if not files or files[0].filename == '':
return jsonify({'success': False, 'error': 'No content selected'}), 400 flash('No files provided.', 'warning')
return redirect(url_for('content.upload_media_page'))
# Delete content upload_folder = current_app.config['UPLOAD_FOLDER']
deleted_count = 0 os.makedirs(upload_folder, exist_ok=True)
for content_id in content_ids:
content = Content.query.get(content_id) uploaded_count = 0
if content:
# Delete file for file in files:
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename) if file.filename == '':
if os.path.exists(filepath): continue
os.remove(filepath)
filename = secure_filename(file.filename)
db.session.delete(content) filepath = os.path.join(upload_folder, filename)
deleted_count += 1
# Check if file already exists
existing = Content.query.filter_by(filename=filename).first()
if existing:
log_action('warning', f'File {filename} already exists, skipping')
continue
# Save file
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']:
detected_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
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}')
elif file_ext == 'pdf':
detected_type = 'pdf'
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
# 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() db.session.commit()
# Clear caches
cache.clear() cache.clear()
log_action('info', f'Bulk deleted {deleted_count} content items') log_action('info', f'Uploaded {uploaded_count} media files')
return jsonify({'success': True, 'deleted': deleted_count})
if playlist_id:
playlist = Playlist.query.get(playlist_id)
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
else:
flash(f'Successfully uploaded {uploaded_count} file(s) to media library!', 'success')
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
log_action('error', f'Error bulk deleting content: {str(e)}') log_action('error', f'Error uploading media: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500 flash('Error uploading media files.', 'danger')
return redirect(url_for('content.upload_media_page'))
@content_bp.route('/upload-progress/<upload_id>') @content_bp.route('/player/<int:player_id>/assign-playlist', methods=['POST'])
@login_required @login_required
def upload_progress_status(upload_id: str): def assign_player_to_playlist(player_id: int):
"""Get upload progress for a specific upload.""" """Assign a player to a playlist."""
progress = get_upload_progress(upload_id) player = Player.query.get_or_404(player_id)
return jsonify(progress)
@content_bp.route('/preview/<int:content_id>')
@login_required
def preview_content(content_id: int):
"""Preview content in browser."""
try: try:
content = Content.query.get_or_404(content_id) playlist_id = request.form.get('playlist_id', type=int)
# Serve file from uploads folder if playlist_id:
return send_from_directory( playlist = Playlist.query.get_or_404(playlist_id)
current_app.config['UPLOAD_FOLDER'], player.playlist_id = playlist_id
content.filename, log_action('info', f'Assigned player "{player.name}" to playlist "{playlist.name}"')
as_attachment=False flash(f'Player "{player.name}" assigned to playlist "{playlist.name}".', 'success')
) else:
except Exception as e: player.playlist_id = None
log_action('error', f'Error previewing content: {str(e)}') log_action('info', f'Unassigned player "{player.name}" from playlist')
return "Error loading content", 500 flash(f'Player "{player.name}" unassigned from playlist.', 'success')
@content_bp.route('/<int:content_id>/download')
@login_required
def download_content(content_id: int):
"""Download content file."""
try:
content = Content.query.get_or_404(content_id)
log_action('info', f'Content "{content.filename}" downloaded') db.session.commit()
cache.clear()
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=True
)
except Exception as e:
log_action('error', f'Error downloading content: {str(e)}')
return "Error downloading content", 500
@content_bp.route('/statistics')
@login_required
def content_statistics():
"""Get content statistics."""
try:
total_content = Content.query.count()
# Count by type
type_counts = {}
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
count = Content.query.filter_by(content_type=content_type).count()
type_counts[content_type] = count
# Calculate total storage
upload_folder = current_app.config['UPLOAD_FOLDER']
total_size = 0
if os.path.exists(upload_folder):
for dirpath, dirnames, filenames in os.walk(upload_folder):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
return jsonify({
'total': total_content,
'by_type': type_counts,
'total_size_mb': round(total_size / (1024 * 1024), 2)
})
except Exception as e: except Exception as e:
log_action('error', f'Error getting content statistics: {str(e)}') db.session.rollback()
return jsonify({'error': str(e)}), 500 log_action('error', f'Error assigning player to playlist: {str(e)}')
flash('Error assigning player to playlist.', 'danger')
@content_bp.route('/check-duplicates') return redirect(url_for('content.content_list'))
@login_required
def check_duplicates():
"""Check for duplicate filenames."""
try:
# Get all filenames
all_content = Content.query.all()
filename_counts = {}
for content in all_content:
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
# Find duplicates
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
return jsonify({
'has_duplicates': len(duplicates) > 0,
'duplicates': duplicates
})
except Exception as e:
log_action('error', f'Error checking duplicates: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/<int:content_id>/groups')
@login_required
def content_groups_info(content_id: int):
"""Get groups that contain this content."""
try:
content = Content.query.get_or_404(content_id)
groups_data = []
for group in content.groups:
groups_data.append({
'id': group.id,
'name': group.name,
'description': group.description,
'player_count': group.players.count()
})
return jsonify({
'content_id': content_id,
'filename': content.filename,
'groups': groups_data
})
except Exception as e:
log_action('error', f'Error getting content groups: {str(e)}')
return jsonify({'error': str(e)}), 500

View File

@@ -0,0 +1,500 @@
"""Content blueprint for media upload and management."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app, send_from_directory)
from flask_login import login_required
from werkzeug.utils import secure_filename
import os
from typing import Optional, Dict
import json
from app.extensions import db, cache
from app.models import Content, Group
from app.utils.logger import log_action
from app.utils.uploads import (
save_uploaded_file,
process_video_file,
process_pdf_file,
get_upload_progress,
set_upload_progress
)
content_bp = Blueprint('content', __name__, url_prefix='/content')
# In-memory storage for upload progress (for simple demo; use Redis in production)
upload_progress = {}
@content_bp.route('/')
@login_required
def content_list():
"""Display list of all content."""
try:
# Get all unique content files (by filename)
from sqlalchemy import func
# Get content with player information
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
# Group content by filename to show which players have each file
content_map = {}
for content in contents:
if content.filename not in content_map:
content_map[content.filename] = {
'content': content,
'players': [],
'groups': []
}
# Add player info if assigned to a player
if content.player_id:
from app.models import Player
player = Player.query.get(content.player_id)
if player:
content_map[content.filename]['players'].append({
'id': player.id,
'name': player.name,
'group': player.group.name if player.group else None
})
# Convert to list for template
content_list = []
for filename, data in content_map.items():
content_list.append({
'filename': filename,
'content_type': data['content'].content_type,
'duration': data['content'].duration,
'file_size': data['content'].file_size_mb,
'uploaded_at': data['content'].uploaded_at,
'players': data['players'],
'player_count': len(data['players'])
})
# Sort by upload date
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
return render_template('content/content_list.html',
content_list=content_list)
except Exception as e:
log_action('error', f'Error loading content list: {str(e)}')
flash('Error loading content list.', 'danger')
return redirect(url_for('main.dashboard'))
@content_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload_content():
"""Upload new content."""
if request.method == 'GET':
# Get parameters for return URL and pre-selection
player_id = request.args.get('player_id', type=int)
return_url = request.args.get('return_url', url_for('content.content_list'))
# Get all players for selection
from app.models import Player
players = Player.query.order_by(Player.name).all()
return render_template('content/upload_content.html',
players=players,
selected_player_id=player_id,
return_url=return_url)
try:
# Get form data
player_id = request.form.get('player_id', type=int)
media_type = request.form.get('media_type', 'image')
duration = request.form.get('duration', type=int, default=10)
session_id = request.form.get('session_id', os.urandom(8).hex())
return_url = request.form.get('return_url', url_for('content.content_list'))
# Get files
files = request.files.getlist('files')
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_content'))
if not player_id:
flash('Please select a player.', 'warning')
return redirect(url_for('content.upload_content'))
# Initialize progress tracking using shared utility
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
# Process each file
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
processed_count = 0
total_files = len(files)
for idx, file in enumerate(files):
if file.filename == '':
continue
# Update progress
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
set_upload_progress(session_id, progress_pct,
f'Processing file {idx + 1} of {total_files}...', 'processing')
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename)
# Save file
file.save(filepath)
# Determine content type
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
content_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
content_type = 'video'
# Process video (convert to Raspberry Pi optimized format)
set_upload_progress(session_id, progress_pct + 5,
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
success, message = process_video_file(filepath, session_id)
if not success:
log_action('error', f'Video optimization failed: {message}')
continue # Skip this file and move to next
elif file_ext == 'pdf':
content_type = 'pdf'
# Process PDF (convert to images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PDF {idx + 1}...', 'processing')
# process_pdf_file(filepath, session_id)
elif file_ext in ['ppt', 'pptx']:
content_type = 'presentation'
# Process presentation (convert to PDF then images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PowerPoint {idx + 1}...', 'processing')
# This would call pptx_converter utility
else:
content_type = 'other'
# Create content record linked to player
from app.models import Player
player = Player.query.get(player_id)
if player:
new_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath),
player_id=player_id
)
db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
processed_count += 1
# Commit all changes
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
db.session.commit()
# Complete
set_upload_progress(session_id, 100,
f'Successfully uploaded {processed_count} file(s)!', 'complete')
# Clear all playlist caches
cache.clear()
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
return redirect(return_url)
except Exception as e:
db.session.rollback()
# Update progress to error state
if 'session_id' in locals():
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
log_action('error', f'Error uploading content: {str(e)}')
flash('Error uploading content. Please try again.', 'danger')
return redirect(url_for('content.upload_content'))
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_content(content_id: int):
"""Edit content metadata."""
content = Content.query.get_or_404(content_id)
if request.method == 'GET':
return render_template('content/edit_content.html', content=content)
try:
duration = request.form.get('duration', type=int)
description = request.form.get('description', '').strip()
# Update content
if duration is not None:
content.duration = duration
content.description = description or None
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
flash(f'Content "{content.filename}" updated successfully.', 'success')
return redirect(url_for('content.content_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating content: {str(e)}')
flash('Error updating content. Please try again.', 'danger')
return redirect(url_for('content.edit_content', content_id=content_id))
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
@login_required
def delete_content(content_id: int):
"""Delete content and associated file."""
try:
content = Content.query.get_or_404(content_id)
filename = content.filename
# Delete file from disk
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
# Delete from database
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
flash(f'Content "{filename}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content: {str(e)}')
flash('Error deleting content. Please try again.', 'danger')
return redirect(url_for('content.content_list'))
@content_bp.route('/delete-by-filename', methods=['POST'])
@login_required
def delete_by_filename():
"""Delete all content entries with a specific filename."""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({'success': False, 'message': 'No filename provided'}), 400
# Find all content entries with this filename
contents = Content.query.filter_by(filename=filename).all()
if not contents:
return jsonify({'success': False, 'message': 'Content not found'}), 404
deleted_count = len(contents)
# Delete file from disk (only once)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Deleted file from disk: {filename}')
# Delete all database entries
for content in contents:
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
return jsonify({
'success': True,
'message': f'Content deleted from {deleted_count} playlist(s)',
'deleted_count': deleted_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content by filename: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/bulk/delete', methods=['POST'])
@login_required
def bulk_delete_content():
"""Delete multiple content items at once."""
try:
content_ids = request.json.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'error': 'No content selected'}), 400
# Delete content
deleted_count = 0
for content_id in content_ids:
content = Content.query.get(content_id)
if content:
# Delete file
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(content)
deleted_count += 1
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Bulk deleted {deleted_count} content items')
return jsonify({'success': True, 'deleted': deleted_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk deleting content: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@content_bp.route('/upload-progress/<upload_id>')
@login_required
def upload_progress_status(upload_id: str):
"""Get upload progress for a specific upload."""
progress = get_upload_progress(upload_id)
return jsonify(progress)
@content_bp.route('/preview/<int:content_id>')
@login_required
def preview_content(content_id: int):
"""Preview content in browser."""
try:
content = Content.query.get_or_404(content_id)
# Serve file from uploads folder
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=False
)
except Exception as e:
log_action('error', f'Error previewing content: {str(e)}')
return "Error loading content", 500
@content_bp.route('/<int:content_id>/download')
@login_required
def download_content(content_id: int):
"""Download content file."""
try:
content = Content.query.get_or_404(content_id)
log_action('info', f'Content "{content.filename}" downloaded')
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=True
)
except Exception as e:
log_action('error', f'Error downloading content: {str(e)}')
return "Error downloading content", 500
@content_bp.route('/statistics')
@login_required
def content_statistics():
"""Get content statistics."""
try:
total_content = Content.query.count()
# Count by type
type_counts = {}
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
count = Content.query.filter_by(content_type=content_type).count()
type_counts[content_type] = count
# Calculate total storage
upload_folder = current_app.config['UPLOAD_FOLDER']
total_size = 0
if os.path.exists(upload_folder):
for dirpath, dirnames, filenames in os.walk(upload_folder):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
total_size += os.path.getsize(filepath)
return jsonify({
'total': total_content,
'by_type': type_counts,
'total_size_mb': round(total_size / (1024 * 1024), 2)
})
except Exception as e:
log_action('error', f'Error getting content statistics: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/check-duplicates')
@login_required
def check_duplicates():
"""Check for duplicate filenames."""
try:
# Get all filenames
all_content = Content.query.all()
filename_counts = {}
for content in all_content:
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
# Find duplicates
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
return jsonify({
'has_duplicates': len(duplicates) > 0,
'duplicates': duplicates
})
except Exception as e:
log_action('error', f'Error checking duplicates: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/<int:content_id>/groups')
@login_required
def content_groups_info(content_id: int):
"""Get groups that contain this content."""
try:
content = Content.query.get_or_404(content_id)
groups_data = []
for group in content.groups:
groups_data.append({
'id': group.id,
'name': group.name,
'description': group.description,
'player_count': group.players.count()
})
return jsonify({
'content_id': content_id,
'filename': content.filename,
'groups': groups_data
})
except Exception as e:
log_action('error', f'Error getting content groups: {str(e)}')
return jsonify({'error': str(e)}), 500

View File

@@ -5,8 +5,10 @@ from flask import Blueprint, render_template, redirect, url_for
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app.extensions import db, cache from app.extensions import db, cache
from app.models.player import Player from app.models.player import Player
from app.models.group import Group from app.models.playlist import Playlist
from app.models.content import Content
from app.utils.logger import get_recent_logs from app.utils.logger import get_recent_logs
import os
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)
@@ -16,15 +18,30 @@ main_bp = Blueprint('main', __name__)
@cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer') @cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer')
def dashboard(): def dashboard():
"""Main dashboard page""" """Main dashboard page"""
players = Player.query.all() # Get statistics
groups = Group.query.all() total_players = Player.query.count()
total_playlists = Playlist.query.count()
total_content = Content.query.count()
# Calculate storage usage
upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads')
storage_mb = 0
if os.path.exists(upload_folder):
for filename in os.listdir(upload_folder):
filepath = os.path.join(upload_folder, filename)
if os.path.isfile(filepath):
storage_mb += os.path.getsize(filepath)
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
server_logs = get_recent_logs(20) server_logs = get_recent_logs(20)
return render_template( return render_template(
'dashboard.html', 'dashboard.html',
players=players, total_players=total_players,
groups=groups, total_playlists=total_playlists,
server_logs=server_logs total_content=total_content,
storage_mb=storage_mb,
recent_logs=server_logs
) )

View File

@@ -6,7 +6,7 @@ import secrets
from typing import Optional, List from typing import Optional, List
from app.extensions import db, cache from app.extensions import db, cache
from app.models import Player, Group, Content, PlayerFeedback from app.models import Player, Content, PlayerFeedback, Playlist
from app.utils.logger import log_action from app.utils.logger import log_action
from app.utils.group_player_management import get_player_status_info from app.utils.group_player_management import get_player_status_info
@@ -20,7 +20,7 @@ def list():
"""Display list of all players.""" """Display list of all players."""
try: try:
players = Player.query.order_by(Player.name).all() players = Player.query.order_by(Player.name).all()
groups = Group.query.all() playlists = Playlist.query.all()
# Get player status for each player # Get player status for each player
player_statuses = {} player_statuses = {}
@@ -30,7 +30,7 @@ def list():
return render_template('players/players_list.html', return render_template('players/players_list.html',
players=players, players=players,
groups=groups, playlists=playlists,
player_statuses=player_statuses) player_statuses=player_statuses)
except Exception as e: except Exception as e:
log_action('error', f'Error loading players list: {str(e)}') log_action('error', f'Error loading players list: {str(e)}')
@@ -43,8 +43,7 @@ def list():
def add_player(): def add_player():
"""Add a new player.""" """Add a new player."""
if request.method == 'GET': if request.method == 'GET':
groups = Group.query.order_by(Group.name).all() return render_template('players/add_player.html')
return render_template('players/add_player.html', groups=groups)
try: try:
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
@@ -53,7 +52,6 @@ def add_player():
password = request.form.get('password', '').strip() password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip() quickconnect_code = request.form.get('quickconnect_code', '').strip()
orientation = request.form.get('orientation', 'Landscape') orientation = request.form.get('orientation', 'Landscape')
group_id = request.form.get('group_id')
# Validation # Validation
if not name or len(name) < 3: if not name or len(name) < 3:
@@ -83,8 +81,7 @@ def add_player():
hostname=hostname, hostname=hostname,
location=location or None, location=location or None,
auth_code=auth_code, auth_code=auth_code,
orientation=orientation, orientation=orientation
group_id=int(group_id) if group_id else None
) )
# Set password if provided # Set password if provided
@@ -128,13 +125,11 @@ def edit_player(player_id: int):
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
if request.method == 'GET': if request.method == 'GET':
groups = Group.query.order_by(Group.name).all() return render_template('players/edit_player.html', player=player)
return render_template('players/edit_player.html', player=player, groups=groups)
try: try:
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip() location = request.form.get('location', '').strip()
group_id = request.form.get('group_id')
# Validation # Validation
if not name or len(name) < 3: if not name or len(name) < 3:
@@ -144,7 +139,6 @@ def edit_player(player_id: int):
# Update player # Update player
player.name = name player.name = name
player.location = location or None player.location = location or None
player.group_id = int(group_id) if group_id else None
db.session.commit() db.session.commit()
# Clear cache for this player # Clear cache for this player
@@ -243,6 +237,88 @@ def player_page(player_id: int):
return redirect(url_for('players.list')) return redirect(url_for('players.list'))
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
@login_required
def manage_player(player_id: int):
"""Manage player - edit credentials, assign playlist, view logs."""
player = Player.query.get_or_404(player_id)
if request.method == 'POST':
action = request.form.get('action')
try:
if action == 'update_credentials':
# Update player name, location, orientation
name = request.form.get('name', '').strip()
location = request.form.get('location', '').strip()
orientation = request.form.get('orientation', 'Landscape')
if not name or len(name) < 3:
flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
player.name = name
player.location = location or None
player.orientation = orientation
db.session.commit()
log_action('info', f'Player "{name}" credentials updated')
flash(f'Player "{name}" updated successfully.', 'success')
elif action == 'assign_playlist':
# Assign playlist to player
playlist_id = request.form.get('playlist_id')
if playlist_id:
playlist = Playlist.query.get(int(playlist_id))
if playlist:
player.playlist_id = int(playlist_id)
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player.name}" assigned to playlist "{playlist.name}"')
flash(f'Player assigned to playlist "{playlist.name}".', 'success')
else:
flash('Invalid playlist selected.', 'warning')
else:
# Unassign playlist
player.playlist_id = None
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Player "{player.name}" unassigned from playlist')
flash('Player unassigned from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error managing player: {str(e)}')
flash('Error updating player. Please try again.', 'danger')
return redirect(url_for('players.manage_player', player_id=player_id))
# GET request - show manage page
playlists = Playlist.query.order_by(Playlist.name).all()
# Get player's current playlist
current_playlist = None
if player.playlist_id:
current_playlist = Playlist.query.get(player.playlist_id)
# Get recent feedback/logs from player
recent_logs = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.limit(20)\
.all()
# Get player status
status_info = get_player_status_info(player_id)
return render_template('players/manage_player.html',
player=player,
playlists=playlists,
current_playlist=current_playlist,
recent_logs=recent_logs,
status_info=status_info)
@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)."""
@@ -303,35 +379,11 @@ def get_player_playlist(player_id: int) -> List[dict]:
@players_bp.route('/<int:player_id>/reorder', methods=['POST']) @players_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required @login_required
def reorder_content(player_id: int): def reorder_content(player_id: int):
"""Reorder content for a player's group.""" """Legacy endpoint - Content reordering now handled in playlist management."""
try: return jsonify({
player = Player.query.get_or_404(player_id) 'success': False,
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
if not player.group_id: }), 400
flash('Player is not assigned to a group.', 'warning')
return redirect(url_for('players.player_page', player_id=player_id))
# Get new order from request
content_order = request.json.get('order', [])
# Update positions
for idx, content_id in enumerate(content_order):
content = Content.query.get(content_id)
if content and content in player.group.contents:
content.position = idx
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Content reordered for player {player_id}')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering content: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@players_bp.route('/bulk/delete', methods=['POST']) @players_bp.route('/bulk/delete', methods=['POST'])
@@ -366,35 +418,35 @@ def bulk_delete_players():
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@players_bp.route('/bulk/assign-group', methods=['POST']) @players_bp.route('/bulk/assign-playlist', methods=['POST'])
@login_required @login_required
def bulk_assign_group(): def bulk_assign_playlist():
"""Assign multiple players to a group.""" """Assign multiple players to a playlist."""
try: try:
player_ids = request.json.get('player_ids', []) player_ids = request.json.get('player_ids', [])
group_id = request.json.get('group_id') playlist_id = request.json.get('playlist_id')
if not player_ids: if not player_ids:
return jsonify({'success': False, 'error': 'No players selected'}), 400 return jsonify({'success': False, 'error': 'No players selected'}), 400
# Validate group # Validate playlist
if group_id: if playlist_id:
group = Group.query.get(group_id) playlist = Playlist.query.get(playlist_id)
if not group: if not playlist:
return jsonify({'success': False, 'error': 'Invalid group'}), 400 return jsonify({'success': False, 'error': 'Invalid playlist'}), 400
# Assign players # Assign players
updated_count = 0 updated_count = 0
for player_id in player_ids: for player_id in player_ids:
player = Player.query.get(player_id) player = Player.query.get(player_id)
if player: if player:
player.group_id = group_id player.playlist_id = playlist_id
cache.delete_memoized(get_player_playlist, player_id) cache.delete_memoized(get_player_playlist, player_id)
updated_count += 1 updated_count += 1
db.session.commit() db.session.commit()
log_action('info', f'Bulk assigned {updated_count} players to group {group_id}') log_action('info', f'Bulk assigned {updated_count} players to playlist {playlist_id}')
return jsonify({'success': True, 'updated': updated_count}) return jsonify({'success': True, 'updated': updated_count})
except Exception as e: except Exception as e:

232
app/blueprints/playlist.py Normal file
View File

@@ -0,0 +1,232 @@
"""Playlist blueprint for managing player playlists."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from sqlalchemy import desc
import os
from app.extensions import db, cache
from app.models import Player, Content
from app.utils.logger import log_action
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@playlist_bp.route('/<int:player_id>')
@login_required
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 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]
return render_template('playlist/manage_playlist.html',
player=player,
playlist_content=playlist_content,
available_files=available_files)
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
@login_required
def add_to_playlist(player_id: int):
"""Add content to player's playlist."""
player = Player.query.get_or_404(player_id)
try:
filename = request.form.get('filename')
duration = request.form.get('duration', type=int, default=10)
if not filename:
flash('Please provide a filename.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
# Get max position
max_position = db.session.query(db.func.max(Content.position)).filter_by(
player_id=player_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
)
db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
db.session.commit()
cache.clear()
log_action('info', f'Added "{filename}" to playlist for player "{player.name}"')
flash(f'Added "{filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding to playlist: {str(e)}')
flash('Error adding to playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
@login_required
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')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
filename = content.filename
# Delete content
db.session.delete(content)
# Reorder remaining content
remaining_content = Content.query.filter_by(
player_id=player_id
).order_by(Content.position).all()
for idx, item in enumerate(remaining_content, start=1):
item.position = idx
# Increment playlist version
player.playlist_version += 1
db.session.commit()
cache.clear()
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
flash(f'Removed "{filename}" from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
flash('Error removing from playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder playlist items."""
player = Player.query.get_or_404(player_id)
try:
# Get new order from JSON
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
# Update positions
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
# Increment playlist version
player.playlist_version += 1
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})')
return jsonify({
'success': True,
'message': 'Playlist reordered successfully',
'version': player.playlist_version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
@login_required
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
try:
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
db.session.commit()
cache.clear()
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Duration updated',
'version': player.playlist_version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating duration: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
@login_required
def clear_playlist(player_id: int):
"""Clear all content from player's playlist."""
player = Player.query.get_or_404(player_id)
try:
# Delete all content for this player
Content.query.filter_by(player_id=player_id).delete()
# Increment playlist version
player.playlist_version += 1
db.session.commit()
cache.clear()
log_action('info', f'Cleared playlist for player "{player.name}"')
flash('Playlist cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing playlist: {str(e)}')
flash('Error clearing playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))

View File

@@ -2,6 +2,7 @@
from app.models.user import User from app.models.user import User
from app.models.player import Player from app.models.player import Player
from app.models.group import Group, group_content from app.models.group import Group, group_content
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
@@ -10,8 +11,10 @@ __all__ = [
'User', 'User',
'Player', 'Player',
'Group', 'Group',
'Playlist',
'Content', 'Content',
'ServerLog', 'ServerLog',
'PlayerFeedback', 'PlayerFeedback',
'group_content', 'group_content',
'playlist_content',
] ]

View File

@@ -3,7 +3,6 @@ from datetime import datetime
from typing import Optional, List from typing import Optional, List
from app.extensions import db from app.extensions import db
from app.models.group import group_content
class Content(db.Model): class Content(db.Model):
@@ -13,31 +12,26 @@ class Content(db.Model):
id: Primary key id: Primary key
filename: Original filename filename: Original filename
content_type: Type of content (image, video, pdf, presentation, other) content_type: Type of content (image, video, pdf, presentation, other)
duration: Display duration in seconds duration: Default display duration in seconds
file_size: File size in bytes file_size: File size in bytes
description: Optional content description description: Optional content description
position: Display order position
uploaded_at: Upload timestamp uploaded_at: Upload timestamp
""" """
__tablename__ = 'content' __tablename__ = 'content'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False, index=True) filename = db.Column(db.String(255), nullable=False, unique=True, index=True)
content_type = db.Column(db.String(50), nullable=False, index=True) content_type = db.Column(db.String(50), nullable=False, index=True)
duration = db.Column(db.Integer, default=10, nullable=True) duration = db.Column(db.Integer, default=10, nullable=True)
file_size = db.Column(db.BigInteger, nullable=True) file_size = db.Column(db.BigInteger, nullable=True)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0, index=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
nullable=False, index=True) nullable=False, index=True)
# Player relationship (for direct player assignment) # Relationships - many-to-many with playlists
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), playlists = db.relationship('Playlist', secondary='playlist_content',
nullable=True, index=True) back_populates='contents', lazy='dynamic')
groups = db.relationship('Group', secondary='group_content',
# Relationships
player = db.relationship('Player', back_populates='contents')
groups = db.relationship('Group', secondary=group_content,
back_populates='contents', lazy='dynamic') back_populates='contents', lazy='dynamic')
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@@ -32,7 +32,6 @@ class Group(db.Model):
onupdate=datetime.utcnow, nullable=False) onupdate=datetime.utcnow, nullable=False)
# Relationships # Relationships
players = db.relationship('Player', back_populates='group', lazy='dynamic')
contents = db.relationship('Content', secondary=group_content, contents = db.relationship('Content', secondary=group_content,
back_populates='groups', lazy='dynamic') back_populates='groups', lazy='dynamic')
@@ -40,10 +39,7 @@ class Group(db.Model):
"""String representation of Group.""" """String representation of Group."""
return f'<Group {self.name} (ID={self.id})>' return f'<Group {self.name} (ID={self.id})>'
@property
def player_count(self) -> int:
"""Get number of players in this group."""
return self.players.count()
@property @property
def content_count(self) -> int: def content_count(self) -> int:

View File

@@ -16,10 +16,10 @@ class Player(db.Model):
auth_code: Authentication code for API access (legacy) auth_code: Authentication code for API access (legacy)
password_hash: Hashed password for player authentication password_hash: Hashed password for player authentication
quickconnect_code: Hashed quick connect code for easy pairing quickconnect_code: Hashed quick connect code for easy pairing
group_id: Foreign key to assigned group
orientation: Display orientation (Landscape/Portrait) orientation: Display orientation (Landscape/Portrait)
status: Current player status (online, offline, error) status: Current player status (online, offline, error)
last_seen: Last activity timestamp last_seen: Last activity timestamp
playlist_version: Version number for playlist synchronization
created_at: Player creation timestamp created_at: Player creation timestamp
""" """
__tablename__ = 'player' __tablename__ = 'player'
@@ -31,20 +31,20 @@ class Player(db.Model):
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True) auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False) password_hash = db.Column(db.String(255), nullable=False)
quickconnect_code = db.Column(db.String(255), nullable=True) quickconnect_code = db.Column(db.String(255), nullable=True)
group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True, index=True)
orientation = db.Column(db.String(16), default='Landscape', nullable=False) orientation = db.Column(db.String(16), default='Landscape', nullable=False)
status = db.Column(db.String(50), default='offline', index=True) status = db.Column(db.String(50), default='offline', index=True)
last_seen = db.Column(db.DateTime, nullable=True, index=True) last_seen = db.Column(db.DateTime, nullable=True, index=True)
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True) last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
playlist_version = db.Column(db.Integer, default=1, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Playlist assignment
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
nullable=True, index=True)
# Relationships # Relationships
group = db.relationship('Group', back_populates='players') playlist = db.relationship('Playlist', back_populates='players')
feedback = db.relationship('PlayerFeedback', back_populates='player', feedback = db.relationship('PlayerFeedback', back_populates='player',
cascade='all, delete-orphan', lazy='dynamic') cascade='all, delete-orphan', lazy='dynamic')
contents = db.relationship('Content', back_populates='player',
cascade='all, delete-orphan', lazy='dynamic')
def __repr__(self) -> str: def __repr__(self) -> str:
"""String representation of Player.""" """String representation of Player."""

97
app/models/playlist.py Normal file
View File

@@ -0,0 +1,97 @@
"""Playlist model for managing content collections."""
from datetime import datetime
from typing import List, Optional
from app.extensions import db
# Association table for many-to-many relationship between playlists and content
playlist_content = db.Table('playlist_content',
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('position', db.Integer, default=0),
db.Column('duration', db.Integer, default=10)
)
class Playlist(db.Model):
"""Playlist model representing a collection of content.
Attributes:
id: Primary key
name: Unique playlist name
description: Optional playlist description
version: Version number for synchronization
is_active: Whether playlist is active
created_at: Playlist creation timestamp
updated_at: Last modification timestamp
"""
__tablename__ = 'playlist'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
orientation = db.Column(db.String(20), default='Landscape', nullable=False)
version = db.Column(db.Integer, default=1, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
# Relationships
players = db.relationship('Player', back_populates='playlist', lazy='dynamic')
contents = db.relationship('Content', secondary=playlist_content,
back_populates='playlists', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Playlist."""
return f'<Playlist {self.name} (ID={self.id}, Version={self.version})>'
@property
def player_count(self) -> int:
"""Get number of players assigned to this playlist."""
return self.players.count()
@property
def content_count(self) -> int:
"""Get number of content items in this playlist."""
return self.contents.count()
@property
def total_duration(self) -> int:
"""Calculate total duration of all content in seconds."""
total = 0
for content in self.contents:
total += content.duration or 10
return total
def increment_version(self) -> None:
"""Increment playlist version for sync detection."""
self.version += 1
self.updated_at = datetime.utcnow()
def get_content_ordered(self) -> List:
"""Get content items ordered by position."""
# Query through association table to get position
from sqlalchemy import select
stmt = select(playlist_content.c.content_id,
playlist_content.c.position,
playlist_content.c.duration).where(
playlist_content.c.playlist_id == self.id
).order_by(playlist_content.c.position)
results = db.session.execute(stmt).fetchall()
ordered_content = []
for row in results:
content = db.session.get(Content, row.content_id)
if content:
content._playlist_position = row.position
content._playlist_duration = row.duration
ordered_content.append(content)
return ordered_content
# Import Content here to avoid circular import
from app.models.content import Content

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"></rect>
<polyline points="17 2 12 7 7 2"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 259 B

11
app/static/icons/sun.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>

After

Width:  |  Height:  |  Size: 651 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@@ -5,12 +5,53 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DigiServer v2{% endblock %}</title> <title>{% block title %}DigiServer v2{% endblock %}</title>
<style> <style>
/* Ensure emoji font support */
@supports (font-family: "Apple Color Emoji") {
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
}
}
:root {
/* Light Mode Colors */
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--primary-color: #667eea;
--primary-dark: #5568d3;
--secondary-color: #764ba2;
--bg-color: #f5f7fa;
--card-bg: #ffffff;
--text-color: #2d3748;
--text-secondary: #718096;
--border-color: #e2e8f0;
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1);
}
body.dark-mode {
/* Dark Mode Colors */
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--primary-color: #7c3aed;
--primary-dark: #6d28d9;
--secondary-color: #8b5cf6;
--bg-color: #1a202c;
--card-bg: #2d3748;
--text-color: #e2e8f0;
--text-secondary: #a0aec0;
--border-color: #4a5568;
--header-bg: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6; line-height: 1.6;
color: #333; color: var(--text-color);
background: #f5f5f5; background: var(--bg-color);
transition: background 0.3s, color 0.3s;
} }
.container { .container {
max-width: 1200px; max-width: 1200px;
@@ -18,81 +59,248 @@
padding: 20px; padding: 20px;
} }
header { header {
background: #2c3e50; background: var(--header-bg);
color: white; color: white;
padding: 1rem 0; padding: 1rem 0;
margin-bottom: 2rem; margin-bottom: 2rem;
box-shadow: var(--shadow);
} }
header .container { header .container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
header h1 { font-size: 1.5rem; } header h1 {
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
nav a { nav a {
color: white; color: white;
text-decoration: none; text-decoration: none;
margin-left: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 4px; border-radius: 6px;
transition: background 0.3s; transition: all 0.3s;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
nav a:hover {
background: rgba(255,255,255,0.2);
transform: translateY(-2px);
}
nav a img {
width: 18px;
height: 18px;
filter: brightness(0) invert(1);
}
.dark-mode-toggle {
background: rgba(255,255,255,0.2);
border: 2px solid rgba(255,255,255,0.3);
color: white;
cursor: pointer;
padding: 0.5rem;
border-radius: 6px;
transition: all 0.3s;
margin-left: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.dark-mode-toggle:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.1);
}
.dark-mode-toggle img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* Emoji fallback for systems without emoji fonts */
.emoji-fallback::before {
content: attr(data-emoji);
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
} }
nav a:hover { background: rgba(255,255,255,0.1); }
.alert { .alert {
padding: 1rem; padding: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
border-radius: 4px; border-radius: 8px;
border-left: 4px solid; border-left: 4px solid;
transition: all 0.3s;
} }
.alert-success { .alert-success {
background: #d4edda; background: #d4edda;
border-color: #28a745; border-color: #28a745;
color: #155724; color: #155724;
} }
body.dark-mode .alert-success {
background: rgba(40, 167, 69, 0.2);
border-color: #28a745;
color: #7ce3a3;
}
.alert-danger { .alert-danger {
background: #f8d7da; background: #f8d7da;
border-color: #dc3545; border-color: #dc3545;
color: #721c24; color: #721c24;
} }
body.dark-mode .alert-danger {
background: rgba(220, 53, 69, 0.2);
border-color: #dc3545;
color: #f88f9a;
}
.alert-warning { .alert-warning {
background: #fff3cd; background: #fff3cd;
border-color: #ffc107; border-color: #ffc107;
color: #856404; color: #856404;
} }
body.dark-mode .alert-warning {
background: rgba(255, 193, 7, 0.2);
border-color: #ffc107;
color: #ffd454;
}
.alert-info { .alert-info {
background: #d1ecf1; background: #d1ecf1;
border-color: #17a2b8; border-color: #17a2b8;
color: #0c5460; color: #0c5460;
} }
.card { body.dark-mode .alert-info {
background: white; background: rgba(23, 162, 184, 0.2);
border-radius: 8px; border-color: #17a2b8;
padding: 1.5rem; color: #7dd3e0;
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
.card h2 { margin-bottom: 1rem; color: #2c3e50; } .card {
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow);
transition: all 0.3s;
border: 1px solid var(--border-color);
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.card h2 {
margin-bottom: 1rem;
color: var(--text-color);
font-weight: 600;
}
.card h3 {
color: var(--text-color);
}
/* Card with gradient header */
.card-header {
background: var(--primary-gradient);
color: white;
padding: 20px;
border-radius: 12px 12px 0 0;
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
}
.card-header h2 {
margin: 0;
color: white;
}
.btn { .btn {
display: inline-block; display: inline-block;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #3498db; background: var(--primary-color);
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: 6px;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: background 0.3s; transition: all 0.3s;
font-weight: 500;
}
.btn:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary {
background: var(--primary-gradient);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
} }
.btn:hover { background: #2980b9; }
.btn-danger { background: #e74c3c; } .btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; } .btn-danger:hover {
background: #c0392b;
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
}
.btn-success { background: #27ae60; } .btn-success { background: #27ae60; }
.btn-success:hover { background: #229954; } .btn-success:hover {
background: #229954;
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
}
.btn-info {
background: #3498db;
}
.btn-info:hover {
background: #2980b9;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
/* Form Inputs */
input, textarea, select {
background: var(--card-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
transition: all 0.3s;
}
input:focus, textarea:focus, select:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
body.dark-mode input:focus,
body.dark-mode textarea:focus,
body.dark-mode select:focus {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
}
/* Tables */
table {
background: var(--card-bg);
color: var(--text-color);
}
th {
background: var(--bg-color);
color: var(--text-color);
}
tr {
border-bottom: 1px solid var(--border-color);
}
/* Code blocks */
code, pre {
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
footer { footer {
margin-top: 2rem; margin-top: 2rem;
padding: 1rem 0; padding: 1rem 0;
text-align: center; text-align: center;
color: #7f8c8d; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
</style> </style>
@@ -101,17 +309,31 @@
<body> <body>
<header> <header>
<div class="container"> <div class="container">
<h1>📺 DigiServer v2</h1> <h1>
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="DigiServer" style="width: 28px; height: 28px; filter: brightness(0) invert(1);">
DigiServer v2
</h1>
<nav> <nav>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}">Dashboard</a> <a href="{{ url_for('main.dashboard') }}">
<a href="{{ url_for('players.list') }}">Players</a> <img src="{{ url_for('static', filename='icons/home.svg') }}" alt="">
<a href="{{ url_for('groups.groups_list') }}">Groups</a> Dashboard
<a href="{{ url_for('content.content_list') }}">Content</a> </a>
<a href="{{ url_for('players.list') }}">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="">
Players
</a>
<a href="{{ url_for('content.content_list') }}">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
Playlists
</a>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ url_for('admin.admin_panel') }}">Admin</a> <a href="{{ url_for('admin.admin_panel') }}">Admin</a>
{% endif %} {% endif %}
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a> <a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">
</button>
{% else %} {% else %}
<a href="{{ url_for('auth.login') }}">Login</a> <a href="{{ url_for('auth.login') }}">Login</a>
<a href="{{ url_for('auth.register') }}">Register</a> <a href="{{ url_for('auth.register') }}">Register</a>
@@ -140,6 +362,38 @@
</div> </div>
</footer> </footer>
<script>
// Dark Mode Toggle
function toggleDarkMode() {
const body = document.body;
const themeIcon = document.getElementById('theme-icon');
body.classList.toggle('dark-mode');
// Update icon
if (body.classList.contains('dark-mode')) {
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
localStorage.setItem('darkMode', 'enabled');
} else {
themeIcon.src = "{{ url_for('static', filename='icons/moon.svg') }}";
localStorage.setItem('darkMode', 'disabled');
}
}
// Load saved theme preference
document.addEventListener('DOMContentLoaded', function() {
const darkMode = localStorage.getItem('darkMode');
const themeIcon = document.getElementById('theme-icon');
if (darkMode === 'enabled') {
document.body.classList.add('dark-mode');
if (themeIcon) {
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
}
}
});
</script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,438 @@
{% extends "base.html" %}
{% block title %}Playlist Management - DigiServer v2{% endblock %}
{% block content %}
<style>
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.full-width {
grid-column: 1 / -1;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px 8px 0 0;
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
}
.card-header h2 {
margin: 0;
color: white;
}
.playlist-list {
max-height: 400px;
overflow-y: auto;
}
.playlist-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 6px;
border-left: 4px solid #667eea;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.playlist-item:hover {
background: #e9ecef;
transform: translateX(5px);
}
.playlist-info h3 {
margin: 0 0 5px 0;
font-size: 18px;
}
.playlist-stats {
font-size: 14px;
color: #6c757d;
}
.playlist-actions {
display: flex;
gap: 10px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 600;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-sm {
padding: 5px 10px;
font-size: 13px;
}
.upload-zone {
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 30px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
}
.upload-zone:hover {
border-color: #667eea;
background: #f0f2ff;
}
.upload-zone.drag-over {
border-color: #667eea;
background: #e7e9ff;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.file-input-wrapper input[type=file] {
position: absolute;
left: -9999px;
}
.media-library {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.media-item {
background: white;
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 10px;
text-align: center;
transition: all 0.2s;
cursor: pointer;
}
.media-item:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.media-icon {
font-size: 48px;
margin-bottom: 10px;
}
.media-name {
font-size: 12px;
word-break: break-word;
}
</style>
<div class="container" style="max-width: 1400px;">
<h1 style="margin-bottom: 25px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
Playlist Management
</h1>
<div class="main-grid">
<!-- Create/Manage Playlists Card -->
<div class="card">
<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);">
Playlists
</h2>
</div>
<!-- Create New Playlist Form -->
<form method="POST" action="{{ url_for('content.create_playlist') }}" style="margin-bottom: 25px;">
<h3 style="margin-bottom: 15px;">Create New Playlist</h3>
<div class="form-group">
<label for="playlist_name">Playlist Name *</label>
<input type="text" name="name" id="playlist_name" class="form-control" required
placeholder="e.g., Main Lobby Display">
</div>
<div class="form-group">
<label for="playlist_orientation">Content Orientation *</label>
<select name="orientation" id="playlist_orientation" class="form-control" required>
<option value="Landscape">Landscape (Horizontal)</option>
<option value="Portrait">Portrait (Vertical)</option>
</select>
<small style="color: #6c757d; font-size: 12px; display: block; margin-top: 5px;">
Select the orientation that matches your display screens
</small>
</div>
<div class="form-group">
<label for="playlist_description">Description (Optional)</label>
<textarea name="description" id="playlist_description" class="form-control"
placeholder="Describe the purpose of this playlist..."></textarea>
</div>
<button type="submit" class="btn btn-primary">
Create Playlist
</button>
</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>
<!-- Upload Media Card -->
<div class="card">
<div class="card-header">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Upload Media
</h2>
</div>
<div style="text-align: center; padding: 40px 20px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.5; margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Upload Media Files</h3>
<p style="color: #6c757d; margin-bottom: 25px;">
Upload images, videos, and PDFs to your media library.<br>
Assign them to playlists during or after upload.
</p>
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="padding: 15px 40px; font-size: 16px; 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);">
Go to Upload Page
</a>
</div>
<!-- Media Library Preview -->
<hr style="margin: 25px 0;">
<h3 style="margin-bottom: 15px;">Media Library ({{ media_files|length }} files)</h3>
<div class="media-library">
{% if media_files %}
{% for media in media_files[:12] %}
<div class="media-item" title="{{ media.filename }}">
<div class="media-icon">
{% if media.content_type == 'image' %}
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="Image" style="width: 48px; height: 48px; opacity: 0.5;">
{% elif media.content_type == 'video' %}
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="Video" style="width: 48px; height: 48px; opacity: 0.5;">
{% elif media.content_type == 'pdf' %}
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="PDF" style="width: 48px; height: 48px; opacity: 0.5;">
{% else %}
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="File" style="width: 48px; height: 48px; opacity: 0.5;">
{% endif %}
</div>
<div class="media-name">{{ media.filename[:20] }}...</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 20px; color: #999;">
<p>No media files yet. Upload your first file!</p>
</div>
{% endif %}
</div>
{% if media_files|length > 12 %}
<p style="text-align: center; margin-top: 15px; color: #999;">
+ {{ media_files|length - 12 }} more files
</p>
{% endif %}
</div>
</div>
<!-- Assign Players to 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/monitor.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
Player Assignments
</h2>
</div>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Player 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;">Assigned Playlist</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;"><strong>{{ player.name }}</strong></td>
<td style="padding: 12px;">
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">
{{ player.hostname }}
</code>
</td>
<td style="padding: 12px;">{{ player.location or '-' }}</td>
<td style="padding: 12px;">
<form method="POST" action="{{ url_for('content.assign_player_to_playlist', player_id=player.id) }}"
style="display: inline;">
<select name="playlist_id" class="form-control" style="width: auto; display: inline-block;"
onchange="this.form.submit()">
<option value="">No Playlist</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}"
{% if player.playlist_id == playlist.id %}selected{% endif %}>
{{ playlist.name }}
</option>
{% endfor %}
</select>
</form>
</td>
<td style="padding: 12px;">
{% if player.is_online %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
⚫ Offline
</span>
{% endif %}
</td>
<td style="padding: 12px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm">
👁️ View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script>
// File upload handling
const fileInput = document.getElementById('file-input');
const uploadZone = document.getElementById('upload-zone');
const fileList = document.getElementById('file-list');
const uploadBtn = document.getElementById('upload-btn');
fileInput.addEventListener('change', handleFiles);
// Drag and drop
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('drag-over');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('drag-over');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
fileInput.files = e.dataTransfer.files;
handleFiles();
});
function handleFiles() {
const files = fileInput.files;
fileList.innerHTML = '';
if (files.length > 0) {
uploadBtn.disabled = false;
const ul = document.createElement('ul');
ul.style.cssText = 'list-style: none; padding: 0;';
for (let file of files) {
const li = document.createElement('li');
li.style.cssText = 'padding: 8px; background: #f8f9fa; margin-bottom: 5px; border-radius: 4px;';
li.textContent = `📎 ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
ul.appendChild(li);
}
fileList.appendChild(ul);
} else {
uploadBtn.disabled = true;
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,304 @@
{% extends "base.html" %}
{% block title %}Manage {{ playlist.name }} - DigiServer v2{% endblock %}
{% block content %}
<style>
.playlist-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 12px;
margin-bottom: 25px;
}
.playlist-header h1 {
margin: 0 0 10px 0;
}
.stats-row {
display: flex;
gap: 30px;
margin-top: 15px;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
opacity: 0.9;
}
.stat-value {
font-size: 24px;
font-weight: bold;
}
.content-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
}
.playlist-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
border-bottom: 2px solid #dee2e6;
}
.playlist-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.draggable-row {
cursor: move;
}
.draggable-row:hover {
background: #f8f9fa;
}
.drag-handle {
cursor: grab;
font-size: 18px;
color: #999;
}
.available-content {
max-height: 500px;
overflow-y: auto;
}
.content-item {
background: #f8f9fa;
padding: 12px;
margin-bottom: 10px;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
<div class="container" style="max-width: 1400px;">
<div class="playlist-header">
<h1>🎬 {{ playlist.name }}</h1>
{% if playlist.description %}
<p style="margin: 5px 0; opacity: 0.9;">{{ playlist.description }}</p>
{% endif %}
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">Content Items</span>
<span class="stat-value">{{ playlist_content|length }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Total Duration</span>
<span class="stat-value">{{ playlist.total_duration }}s</span>
</div>
<div class="stat-item">
<span class="stat-label">Version</span>
<span class="stat-value">{{ playlist.version }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Players Assigned</span>
<span class="stat-value">{{ playlist.player_count }}</span>
</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">
← Back to Playlists
</a>
</div>
<div class="content-grid">
<div class="card">
<h2 style="margin-bottom: 20px;">📋 Playlist Content (Drag to Reorder)</h2>
{% if playlist_content %}
<table class="playlist-table" id="playlist-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th style="width: 50px;">#</th>
<th>Filename</th>
<th style="width: 100px;">Type</th>
<th style="width: 100px;">Duration</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="playlist-tbody">
{% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
<td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
<td>
{% if content.content_type == 'image' %}📷 Image
{% elif content.content_type == 'video' %}🎥 Video
{% elif content.content_type == 'pdf' %}📄 PDF
{% else %}📁 Other{% endif %}
</td>
<td>{{ content._playlist_duration or content.duration }}s</td>
<td>
<form method="POST"
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
style="display: inline;"
onsubmit="return confirm('Remove from playlist?');">
<button type="submit" class="btn btn-danger btn-sm">
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px;">📭</div>
<p>No content in playlist yet. Add content from the right panel.</p>
</div>
{% endif %}
</div>
<div class="card">
<h2 style="margin-bottom: 20px;"> Add Content</h2>
{% if available_content %}
<div class="available-content">
{% for content in available_content %}
<div class="content-item">
<div>
<div>
{% if content.content_type == 'image' %}📷
{% elif content.content_type == 'video' %}🎥
{% elif content.content_type == 'pdf' %}📄
{% else %}📁{% endif %}
{{ content.filename }}
</div>
<div style="font-size: 12px; color: #999;">
{{ content.file_size_mb }} MB
</div>
</div>
<form method="POST"
action="{{ url_for('content.add_content_to_playlist', playlist_id=playlist.id) }}"
style="display: inline;">
<input type="hidden" name="content_id" value="{{ content.id }}">
<input type="hidden" name="duration" value="{{ content.duration }}">
<button type="submit" class="btn btn-success btn-sm">
+ Add
</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<p>All available content has been added to this playlist!</p>
</div>
{% endif %}
</div>
</div>
</div>
<script>
let draggedElement = null;
document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('playlist-tbody');
if (!tbody) return;
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);
});
});
function handleDragStart(e) {
draggedElement = this;
this.style.opacity = '0.5';
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
return false;
}
function handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (draggedElement !== this) {
const tbody = document.getElementById('playlist-tbody');
const allRows = [...tbody.querySelectorAll('.draggable-row')];
const draggedIndex = allRows.indexOf(draggedElement);
const targetIndex = allRows.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedElement, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedElement, this);
}
updateRowNumbers();
saveOrder();
}
return false;
}
function handleDragEnd(e) {
this.style.opacity = '1';
}
function updateRowNumbers() {
const rows = document.querySelectorAll('#playlist-tbody tr');
rows.forEach((row, index) => {
row.querySelector('td:nth-child(2)').textContent = index + 1;
});
}
function saveOrder() {
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
fetch('{{ url_for("content.reorder_playlist_content", 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) {
alert('Error reordering: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
});
}
</script>
{% endblock %}

View File

@@ -12,32 +12,17 @@
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}"> <input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
<div class="card" style="margin-bottom: 20px;"> <div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Target Selection</h3> <h3 style="margin-bottom: 15px;">Select Player</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;"> <div>
<div> <label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target Type:</label> <select name="player_id" id="player_id" class="form-control" required>
<select name="target_type" id="target_type" class="form-control" required onchange="updateTargetIdOptions()"> <option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
<option value="" disabled selected>Select Target Type</option> {% for player in players %}
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option> <option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option> {{ player.name }} - {{ player.location or 'No location' }}
</select> </option>
</div> {% endfor %}
<div> </select>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target ID:</label>
<select name="target_id" id="target_id" class="form-control" required>
{% if target_type == 'player' %}
{% for player in players %}
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.name }}</option>
{% endfor %}
{% elif target_type == 'group' %}
{% for group in groups %}
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
{% else %}
<option value="" disabled selected>Select a Target ID</option>
{% endif %}
</select>
</div>
</div> </div>
</div> </div>
@@ -238,29 +223,7 @@ function pollUploadProgress() {
}, 500); }, 500);
} }
function updateTargetIdOptions() {
const targetType = document.getElementById('target_type').value;
const targetIdSelect = document.getElementById('target_id');
targetIdSelect.innerHTML = '';
if (targetType === 'player') {
const players = {{ players|tojson }};
players.forEach(player => {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.name;
targetIdSelect.appendChild(option);
});
} else if (targetType === 'group') {
const groups = {{ groups|tojson }};
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
targetIdSelect.appendChild(option);
});
}
}
function handleMediaTypeChange() { function handleMediaTypeChange() {
const mediaType = document.getElementById('media_type').value; const mediaType = document.getElementById('media_type').value;

View File

@@ -0,0 +1,361 @@
{% extends "base.html" %}
{% block title %}Upload Media - DigiServer v2{% endblock %}
{% block content %}
<style>
.upload-container {
max-width: 900px;
margin: 0 auto;
}
.upload-zone {
border: 3px dashed #ced4da;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
background: #f8f9fa;
transition: all 0.3s;
cursor: pointer;
}
.upload-zone:hover {
border-color: #667eea;
background: #f0f2ff;
}
.upload-zone.dragover {
border-color: #667eea;
background: #e8ebff;
transform: scale(1.02);
}
.file-input-wrapper {
margin: 20px 0;
}
.file-input-wrapper input[type="file"] {
display: none;
}
.file-list {
margin-top: 20px;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 10px;
}
.file-info {
display: flex;
align-items: center;
gap: 10px;
}
.file-icon {
font-size: 24px;
}
.remove-file {
cursor: pointer;
color: #dc3545;
font-weight: bold;
padding: 5px 10px;
}
.remove-file:hover {
background: #dc3545;
color: white;
border-radius: 4px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
.form-control {
width: 100%;
padding: 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn-upload {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 40px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
width: 100%;
}
.btn-upload:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.btn-upload:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
}
.playlist-selector {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border: 2px solid #dee2e6;
}
.playlist-selector.selected {
border-color: #667eea;
background: #f0f2ff;
}
</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
</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);">
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>
<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">
</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>
<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>
</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>
<script>
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');
const uploadBtn = document.getElementById('upload-btn');
const uploadForm = document.getElementById('upload-form');
let selectedFiles = [];
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop zone when item is dragged over it
['dragenter', 'dragover'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(eventName => {
uploadZone.addEventListener(eventName, () => {
uploadZone.classList.remove('dragover');
});
});
// Handle dropped files
uploadZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
});
// Handle file input
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
// Click upload zone to trigger file input
uploadZone.addEventListener('click', () => {
fileInput.click();
});
function handleFiles(files) {
selectedFiles = Array.from(files);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
}
function displayFiles() {
fileList.innerHTML = '';
if (selectedFiles.length === 0) {
return;
}
selectedFiles.forEach((file, index) => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
const ext = file.name.split('.').pop().toLowerCase();
let icon = '📁';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) icon = '📷';
else if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) icon = '🎥';
else if (ext === 'pdf') icon = '📄';
else if (['ppt', 'pptx'].includes(ext)) icon = '📊';
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
fileItem.innerHTML = `
<div class="file-info">
<span class="file-icon">${icon}</span>
<div>
<div style="font-weight: 600;">${file.name}</div>
<div style="font-size: 12px; color: #6c757d;">${sizeInMB} MB</div>
</div>
</div>
<span class="remove-file" onclick="removeFile(${index})">✕</span>
`;
fileList.appendChild(fileItem);
});
}
function removeFile(index) {
selectedFiles.splice(index, 1);
displayFiles();
uploadBtn.disabled = selectedFiles.length === 0;
// Update file input
const dt = new DataTransfer();
selectedFiles.forEach(file => dt.items.add(file));
fileInput.files = dt.files;
}
// Form submission
uploadForm.addEventListener('submit', (e) => {
if (selectedFiles.length === 0) {
e.preventDefault();
alert('Please select files to upload');
return;
}
uploadBtn.disabled = true;
uploadBtn.innerHTML = '⏳ Uploading...';
});
</script>
{% endblock %}

View File

@@ -7,42 +7,80 @@
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div class="card"> <div class="card">
<h3 style="color: #3498db; margin-bottom: 0.5rem;">👥 Players</h3> <h3 style="color: #3498db; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px;">
Players
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p> <p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
<a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a> <a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
</div> </div>
<div class="card"> <div class="card">
<h3 style="color: #9b59b6; margin-bottom: 0.5rem;">📁 Groups</h3> <h3 style="color: #9b59b6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<p style="font-size: 2rem; font-weight: bold;">{{ total_groups or 0 }}</p> <img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
<a href="{{ url_for('groups.groups_list') }}" class="btn" style="margin-top: 1rem;">View Groups</a> Playlists
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_playlists or 0 }}</p>
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">Manage Playlists</a>
</div> </div>
<div class="card"> <div class="card">
<h3 style="color: #e67e22; margin-bottom: 0.5rem;">🎬 Content</h3> <h3 style="color: #e67e22; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
Media Library
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_content or 0 }}</p> <p style="font-size: 2rem; font-weight: bold;">{{ total_content or 0 }}</p>
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">View Content</a> <p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">Unique media files</p>
</div> </div>
<div class="card"> <div class="card">
<h3 style="color: #27ae60; margin-bottom: 0.5rem;">💾 Storage</h3> <h3 style="color: #27ae60; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Storage
</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ storage_mb or 0 }} MB</p> <p style="font-size: 2rem; font-weight: bold;">{{ storage_mb or 0 }} MB</p>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success" style="margin-top: 1rem;">Upload Content</a> <p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">Total uploads</p>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>Quick Actions</h2> <h2>Quick Actions</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;"> <div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
<a href="{{ url_for('players.add_player') }}" class="btn btn-success"> Add Player</a> <a href="{{ url_for('players.add_player') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
<a href="{{ url_for('groups.create_group') }}" class="btn btn-success"> Create Group</a> <img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">⬆️ Upload Content</a> Add Player
</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" 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);">
Create Playlist
</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: 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>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ url_for('admin.admin_panel') }}" class="btn">⚙️ Admin Panel</a> <a href="{{ url_for('admin.admin_panel') }}" class="btn">Admin Panel</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Workflow Guide
</h2>
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 4px;">
<ol style="line-height: 2; margin: 0; padding-left: 1.5rem;">
<li><strong>Create a Playlist</strong> - Group your content into themed collections</li>
<li><strong>Upload Media</strong> - Add images, videos, or PDFs to your media library</li>
<li><strong>Add Content to Playlist</strong> - Build your playlist with drag-and-drop ordering</li>
<li><strong>Add Player</strong> - Register physical display devices</li>
<li><strong>Assign Playlist</strong> - Connect players to their playlists</li>
<li><strong>Players Auto-Download</strong> - Devices fetch and display content automatically</li>
</ol>
</div>
</div>
{% if recent_logs %} {% if recent_logs %}
<div class="card"> <div class="card">
<h2>Recent Activity</h2> <h2>Recent Activity</h2>
@@ -63,7 +101,8 @@
<div class="card"> <div class="card">
<h2>System Status</h2> <h2>System Status</h2>
<p>✅ All systems operational</p> <p>✅ All systems operational</p>
<p>🔄 Blueprint architecture active</p> <p><EFBFBD> Playlist-centric architecture active</p>
<p>⚡ Flask {{ config.get('FLASK_VERSION', '3.1.0') }}</p> <p>🔄 Groups removed - Streamlined workflow</p>
<p>⚡ DigiServer v2.0</p>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,252 @@
{% extends "base.html" %}
{% block title %}Manage Player - {{ player.name }}{% endblock %}
{% block content %}
<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;">
Manage Player: {{ player.name }}
</h1>
<a href="{{ url_for('players.list') }}" class="btn" style="float: right; 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);">
Back to Players
</a>
</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 %};">
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
Status:
{% if player.status == 'online' %}
<span style="color: #28a745; display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #28a745;">
Online
</span>
{% elif player.status == 'offline' %}
<span style="color: #dc3545; display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 20px; height: 20px; color: #dc3545;">
Offline
</span>
{% else %}
<span style="color: #ffc107; display: flex; align-items: center; gap: 0.3rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #ffc107;">
{{ player.status|title }}
</span>
{% endif %}
</h3>
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
<p><strong>Last Seen:</strong>
{% if player.last_seen %}
{{ player.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
Never
{% endif %}
</p>
<p><strong>Assigned Playlist:</strong>
{% if current_playlist %}
<span style="color: #28a745; font-weight: bold;">{{ current_playlist.name }} (v{{ current_playlist.version }})</span>
{% else %}
<span style="color: #dc3545;">No playlist assigned</span>
{% endif %}
</p>
</div>
<!-- Three Column Layout -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
<!-- Card 1: Edit Credentials -->
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
Edit Credentials
</h2>
<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>
<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;">
</div>
<div style="margin-bottom: 1rem;">
<label for="location" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">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;">
</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;">
<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>
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Save Changes
</button>
</form>
</div>
<!-- Card 2: Assign Playlist -->
<div class="card">
<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;">
Assign Playlist
</h2>
<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;">
<option value="">-- No Playlist (Unassign) --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}"
{% if player.playlist_id == playlist.id %}selected{% endif %}>
{{ playlist.name }} (v{{ playlist.version }}) - {{ playlist.contents.count() }} items
</option>
{% endfor %}
</select>
</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>
<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>
<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') }}
</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;">
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
No playlist currently assigned to this player.
</p>
</div>
{% endif %}
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Assign Playlist
</button>
</form>
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid #ddd;">
<h4>Quick Actions:</h4>
<a href="{{ url_for('content.content_list') }}" class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Create New Playlist
</a>
{% if current_playlist %}
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: 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 Current Playlist
</a>
{% endif %}
</div>
</div>
<!-- Card 3: Player Logs -->
<div class="card">
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
Player Logs
</h2>
<p style="color: #6c757d; font-size: 0.9rem;">Recent feedback from the player device</p>
<div style="max-height: 500px; overflow-y: auto; margin-top: 1rem;">
{% if recent_logs %}
{% for log in recent_logs %}
<div style="padding: 0.75rem; margin-bottom: 0.5rem; border-left: 4px solid
{% if log.status == 'error' %}#dc3545
{% elif log.status == 'warning' %}#ffc107
{% elif log.status == 'playing' %}#28a745
{% else %}#17a2b8{% endif %};
background: #f8f9fa; border-radius: 4px;">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<strong style="color:
{% if log.status == 'error' %}#dc3545
{% elif log.status == 'warning' %}#ffc107
{% elif log.status == 'playing' %}#28a745
{% else %}#17a2b8{% endif %};">
{% if log.status == 'error' %}❌
{% elif log.status == 'warning' %}⚠️
{% elif log.status == 'playing' %}▶️
{% elif log.status == 'restarting' %}🔄
{% else %}{% endif %}
{{ log.status|upper }}
</strong>
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">{{ log.message }}</p>
{% if log.playlist_version %}
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; color: #6c757d;">
Playlist v{{ log.playlist_version }}
</p>
{% endif %}
{% if log.error_details %}
<details style="margin-top: 0.5rem;">
<summary style="cursor: pointer; font-size: 0.85rem; color: #dc3545;">Error Details</summary>
<pre style="margin: 0.5rem 0 0 0; padding: 0.5rem; background: #fff; border: 1px solid #ddd; border-radius: 4px; font-size: 0.8rem; overflow-x: auto;">{{ log.error_details }}</pre>
</details>
{% endif %}
</div>
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
{{ log.timestamp.strftime('%m/%d %H:%M') }}
</small>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align: center; padding: 2rem; color: #6c757d;">
<p>📭 No logs received yet</p>
<p style="font-size: 0.9rem;">Logs will appear here once the player starts sending feedback</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Additional Info Section -->
<div class="card" style="margin-top: 2rem;">
<h2> Player Information</h2>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<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>
</div>
<div>
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
<p><strong>Location:</strong> {{ player.location or 'Not set' }}</p>
</div>
<div>
<p><strong>Last Heartbeat:</strong>
{% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M:%S') }}
{% else %}
Never
{% endif %}
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -27,8 +27,8 @@
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary"> <a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
✏️ Edit Player ✏️ Edit Player
</a> </a>
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" class="btn btn-success"> <a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
📤 Upload Content 🎬 Manage Playlist
</a> </a>
<a href="{{ url_for('players.list') }}" class="btn"> <a href="{{ url_for('players.list') }}" class="btn">
← Back to Players ← Back to Players
@@ -62,18 +62,6 @@
<td style="padding: 10px; font-weight: bold;">Orientation:</td> <td style="padding: 10px; font-weight: bold;">Orientation:</td>
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td> <td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
</tr> </tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Group:</td>
<td style="padding: 10px;">
{% if player.group %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
{{ player.group.name }}
</span>
{% else %}
<span style="color: #6c757d;">No group</span>
{% endif %}
</td>
</tr>
<tr> <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.strftime('%Y-%m-%d %H:%M') }}</td>
@@ -138,77 +126,43 @@
<!-- Playlist Management Card --> <!-- Playlist Management Card -->
<div class="card" style="margin-bottom: 20px;"> <div class="card" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">🎬 Current Playlist</h3> <h3 style="margin: 0;">🎬 Playlist Management</h3>
<div>
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}"
class="btn btn-success btn-sm">
+ Add Content
</a>
</div>
</div> </div>
{% if playlist %} {% if playlist %}
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 15px;"> <div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
<strong>Total Items:</strong> {{ playlist|length }} | <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
<strong>Total Duration:</strong> {% set total_duration = namespace(value=0) %}{% for item in playlist %}{% set total_duration.value = total_duration.value + (item.duration or 10) %}{% endfor %}{{ total_duration.value }}s <div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">
{% set total_duration = namespace(value=0) %}
{% for item in playlist %}
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
{% endfor %}
{{ total_duration.value }}s
</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
</div>
</div>
</div> </div>
{% endif %}
<table style="width: 100%; border-collapse: collapse;"> <a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
<thead> class="btn btn-primary"
<tr style="background: #f8f9fa; text-align: left;"> style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; width: 50px;">Order</th> 🎬 Open Playlist Manager
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">File Name</th> </a>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Duration</th> {% if not playlist %}
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Actions</th> <div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
</tr> ⚠️ No content in playlist. Open the playlist manager to add content.
</thead>
<tbody id="playlist-items">
{% for item in playlist %}
<tr style="border-bottom: 1px solid #dee2e6;" data-content-id="{{ item.id }}">
<td style="padding: 10px; text-align: center;">
<strong>{{ loop.index }}</strong>
</td>
<td style="padding: 10px;">
{{ item.filename }}
</td>
<td style="padding: 10px;">
{% if item.type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ item.duration or 10 }}s
</td>
<td style="padding: 10px;">
<button onclick="moveUp({{ item.id }})" class="btn btn-sm"
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
{% if loop.first %}disabled{% endif %}>
</button>
<button onclick="moveDown({{ item.id }})" class="btn btn-sm"
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
{% if loop.last %}disabled{% endif %}>
</button>
<button onclick="removeFromPlaylist({{ item.id }}, '{{ item.filename }}')"
class="btn btn-danger btn-sm" style="padding: 3px 8px;">
🗑️ Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center;">
⚠️ No content in playlist. <a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" style="color: #856404; text-decoration: underline;">Upload content</a> to get started.
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -270,62 +224,4 @@
</div> </div>
</div> </div>
<script>
function moveUp(contentId) {
updatePlaylistOrder(contentId, 'up');
}
function moveDown(contentId) {
updatePlaylistOrder(contentId, 'down');
}
function updatePlaylistOrder(contentId, direction) {
fetch('/players/{{ player.id }}/playlist/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content_id: contentId,
direction: direction
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error reordering playlist: ' + data.message);
}
})
.catch(error => {
alert('Error reordering playlist: ' + error);
});
}
function removeFromPlaylist(contentId, filename) {
if (confirm(`Remove "${filename}" from this player's playlist?`)) {
fetch('/players/{{ player.id }}/playlist/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content_id: contentId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error removing content: ' + data.message);
}
})
.catch(error => {
alert('Error removing content: ' + error);
});
}
}
</script>
{% endblock %} {% endblock %}

View File

@@ -17,7 +17,6 @@
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Name</th> <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;">Hostname</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th> <th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Group</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th> <th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</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;">Last Seen</th>
@@ -36,13 +35,6 @@
<td style="padding: 12px;"> <td style="padding: 12px;">
{{ player.location or '-' }} {{ player.location or '-' }}
</td> </td>
<td style="padding: 12px;">
{% if player.group %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ player.group.name }}</span>
{% else %}
<span style="color: #6c757d;">No group</span>
{% endif %}
</td>
<td style="padding: 12px;"> <td style="padding: 12px;">
{{ player.orientation or 'Landscape' }} {{ player.orientation or 'Landscape' }}
</td> </td>
@@ -62,25 +54,10 @@
{% endif %} {% endif %}
</td> </td>
<td style="padding: 12px;"> <td style="padding: 12px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}" <a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn btn-info btn-sm" title="View" style="margin-right: 5px;"> class="btn btn-info btn-sm" title="Manage Player">
👁 View Manage
</a> </a>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary btn-sm" title="Edit" style="margin-right: 5px;">
✏️ Edit
</a>
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" title="Fullscreen" target="_blank" style="margin-right: 5px;">
⛶ Full
</a>
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete player \'{{ player.name }}\'?');">
<button type="submit" class="btn btn-danger btn-sm" title="Delete">
🗑️ Delete
</button>
</form>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -0,0 +1,492 @@
{% extends "base.html" %}
{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %}
{% block content %}
<style>
.playlist-container {
max-width: 1200px;
margin: 0 auto;
}
.player-info-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px;
border-radius: 12px;
margin-bottom: 25px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.player-info-card h1 {
margin: 0 0 10px 0;
font-size: 28px;
}
.player-info-card p {
margin: 5px 0;
opacity: 0.9;
}
.playlist-section {
background: white;
border-radius: 12px;
padding: 25px;
margin-bottom: 25px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.section-header h2 {
margin: 0;
font-size: 24px;
color: #333;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
}
.playlist-table thead {
background: #f8f9fa;
}
.playlist-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #dee2e6;
}
.playlist-table td {
padding: 12px;
border-bottom: 1px solid #dee2e6;
}
.playlist-table tr:hover {
background: #f8f9fa;
}
.draggable-row {
cursor: move;
}
.draggable-row.dragging {
opacity: 0.5;
}
.drag-handle {
cursor: grab;
font-size: 18px;
color: #999;
padding-right: 10px;
}
.drag-handle:active {
cursor: grabbing;
}
.btn {
padding: 8px 16px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-decoration: none;
display: inline-block;
transition: all 0.2s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-success:hover {
background: #218838;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-sm {
padding: 5px 10px;
font-size: 12px;
}
.add-content-form {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 5px;
color: #333;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-top: 15px;
}
.stat-item {
background: rgba(255, 255, 255, 0.2);
padding: 12px;
border-radius: 8px;
}
.stat-label {
font-size: 12px;
opacity: 0.9;
margin-bottom: 5px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
}
.empty-state {
text-align: center;
padding: 40px;
color: #999;
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 15px;
}
.duration-input {
width: 80px;
text-align: center;
}
</style>
<div class="playlist-container">
<!-- Player Info Card -->
<div class="player-info-card">
<h1>🎬 {{ player.name }}</h1>
<p>📍 {{ player.location or 'No location' }}</p>
<p>🖥️ Hostname: {{ player.hostname }}</p>
<p>📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}</p>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">Playlist Items</div>
<div class="stat-value">{{ playlist_content|length }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Playlist Version</div>
<div class="stat-value">{{ player.playlist_version }}</div>
</div>
<div class="stat-item">
<div class="stat-label">Total Duration</div>
<div class="stat-value">{{ playlist_content|sum(attribute='duration') }}s</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}" class="btn btn-secondary">
← Back to Player
</a>
<a href="{{ url_for('content.upload_content', player_id=player.id, return_url=url_for('playlist.manage_playlist', player_id=player.id)) }}"
class="btn btn-success">
Upload New Content
</a>
{% if playlist_content %}
<form method="POST" action="{{ url_for('playlist.clear_playlist', player_id=player.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to clear the entire playlist?');">
<button type="submit" class="btn btn-danger">
🗑️ Clear Playlist
</button>
</form>
{% endif %}
</div>
<!-- Current Playlist -->
<div class="playlist-section">
<div class="section-header">
<h2>📋 Current Playlist</h2>
<span style="color: #999; font-size: 14px;">
Drag and drop to reorder
</span>
</div>
{% if playlist_content %}
<table class="playlist-table" id="playlist-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th style="width: 50px;">#</th>
<th>Filename</th>
<th style="width: 100px;">Type</th>
<th style="width: 120px;">Duration (s)</th>
<th style="width: 100px;">Size</th>
<th style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody id="playlist-tbody">
{% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
<td>
<span class="drag-handle">⋮⋮</span>
</td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
<td>
{% if content.content_type == 'image' %}📷 Image
{% elif content.content_type == 'video' %}🎥 Video
{% elif content.content_type == 'pdf' %}📄 PDF
{% else %}📁 {{ content.content_type }}
{% endif %}
</td>
<td>
<input type="number"
class="form-control duration-input"
value="{{ content.duration }}"
min="1"
onchange="updateDuration({{ content.id }}, this.value)">
</td>
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
<td>
<form method="POST"
action="{{ url_for('playlist.remove_from_playlist', player_id=player.id, content_id=content.id) }}"
style="display: inline;"
onsubmit="return confirm('Remove {{ content.filename }} from playlist?');">
<button type="submit" class="btn btn-danger btn-sm">
✕ Remove
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h3>No content in playlist</h3>
<p>Upload content or add existing files to get started</p>
</div>
{% endif %}
</div>
<!-- Add Content Section -->
{% if available_files %}
<div class="playlist-section">
<div class="section-header">
<h2> Add Existing Content</h2>
</div>
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
class="add-content-form">
<div class="form-group">
<label for="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>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="duration">Display Duration (seconds):</label>
<input type="number"
name="duration"
id="duration"
class="form-control"
value="10"
min="1"
required>
</div>
<button type="submit" class="btn btn-success">
Add to Playlist
</button>
</form>
</div>
{% endif %}
</div>
<script>
let draggedElement = null;
// Initialize drag and drop
document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('playlist-tbody');
if (!tbody) return;
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);
});
});
function handleDragStart(e) {
draggedElement = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
}
function handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (draggedElement !== this) {
const tbody = document.getElementById('playlist-tbody');
const allRows = [...tbody.querySelectorAll('.draggable-row')];
const draggedIndex = allRows.indexOf(draggedElement);
const targetIndex = allRows.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedElement, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedElement, this);
}
updateRowNumbers();
saveOrder();
}
return false;
}
function handleDragEnd(e) {
this.classList.remove('dragging');
}
function updateRowNumbers() {
const rows = document.querySelectorAll('#playlist-tbody tr');
rows.forEach((row, index) => {
row.querySelector('td:nth-child(2)').textContent = index + 1;
});
}
function saveOrder() {
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
fetch('{{ url_for("playlist.reorder_playlist", player_id=player.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content_ids: contentIds })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Playlist reordered successfully');
} else {
alert('Error reordering playlist: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error reordering playlist');
});
}
function updateDuration(contentId, duration) {
const formData = new FormData();
formData.append('duration', duration);
fetch(`{{ url_for("playlist.update_duration", player_id=player.id, content_id=0) }}`.replace('/0', `/${contentId}`), {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('Duration updated successfully');
// Update total duration
location.reload();
} else {
alert('Error updating duration: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('Error updating duration');
});
}
</script>
{% endblock %}

10
install_emoji_fonts.sh Normal file
View File

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

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Add orientation column to playlist table - Direct SQLite approach"""
import sqlite3
import os
def add_orientation_column():
"""Add orientation column to playlist table using direct SQLite connection."""
db_path = 'instance/dev.db'
if not os.path.exists(db_path):
print(f"❌ Database not found at: {db_path}")
return
print(f"📁 Opening database: {db_path}")
try:
# Connect to database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if column already exists
cursor.execute("PRAGMA table_info(playlist)")
columns = [row[1] for row in cursor.fetchall()]
if 'orientation' in columns:
print("✅ Column 'orientation' already exists in playlist table")
conn.close()
return
# Add the column
print("Adding 'orientation' column to playlist table...")
cursor.execute("""
ALTER TABLE playlist
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
""")
conn.commit()
print("✅ Successfully added 'orientation' column to playlist table")
print(" Default value: 'Landscape'")
# Verify the column was added
cursor.execute("PRAGMA table_info(playlist)")
columns = [row[1] for row in cursor.fetchall()]
if 'orientation' in columns:
print("✓ Verified: Column exists in database")
conn.close()
except sqlite3.Error as e:
print(f"❌ SQLite Error: {e}")
return
except Exception as e:
print(f"❌ Error: {e}")
return
if __name__ == '__main__':
add_orientation_column()