feat: Complete template management system with live switching and persistent storage

- Template Management System: Complete HTML overlay template upload, delete, and real-time editing
- Persistent Template Storage: Cross-platform user template storage (AppData/Library/config)
- Template Priority System: Uploaded templates override built-in templates with same filename
- Custom URL Scheme Handler: overlay:// protocol enables uploaded templates to access overlay.js
- Live Template Switching: Dashboard forms instantly switch to selected template with overlay updates
- Template Code Viewer: View and copy template source code with syntax highlighting
- File Watcher Integration: Automatic template reloading when files change on disk
- Enhanced Template UI: Popup previews, source viewer, dynamic template selection

Fixes:
- API endpoint mismatch: Fixed /api/overlay/update vs /api/overlay URL inconsistency
- Template selection: Fixed video test page template selection with auto .html extension
- JavaScript accessibility: Custom overlay:// scheme allows uploaded templates to access overlay.js
- MessageBuilder: Added missing overlay_update() method
- Template reload logic: Load specific template from form instead of reloading current

Technical details:
- OverlayUrlSchemeHandler for JavaScript file serving via overlay:// protocol
- Qt Resource Collections (QRC) integration for embedded JavaScript files
- PyInstaller compatibility with persistent user data outside executable bundle
- WebChannel communication enhanced between Qt application and JavaScript overlays
- Cross-platform persistent storage paths with proper fallback mechanisms
parent 9ea25a11
......@@ -2,6 +2,45 @@
All notable changes to this project will be documented in this file.
## [1.2.2] - 2025-08-21
### Added
- **Template Management System**: Complete HTML overlay template management with upload, delete, and real-time editing capabilities
- **Persistent Template Storage**: Cross-platform user template storage (Windows: %APPDATA%, macOS: ~/Library/Application Support, Linux: ~/.config)
- **Template Priority System**: Uploaded templates automatically override built-in templates with same filename
- **Custom URL Scheme Handler**: overlay:// protocol enables uploaded templates to access JavaScript overlay.js functionality
- **Template Code Viewer**: View and copy template source code directly from web interface with syntax highlighting
- **Live Template Switching**: Dashboard and video control forms now instantly switch to selected template with overlay updates
- **Template File Watcher**: Automatic template reloading when template files change on disk (using watchdog library)
- **Enhanced Template UI**:
- Popup preview windows for better template testing
- Template source code modal with copy-to-clipboard functionality
- Dynamic template dropdown population from API
- Comprehensive template management interface
### Fixed
- **API Endpoint Mismatch**: Fixed frontend calling wrong overlay update URL (/api/overlay/update vs /api/overlay)
- **Template Selection Issues**: Fixed template selection not working from video test page by adding automatic .html extension handling
- **JavaScript Accessibility**: Solved uploaded templates unable to access overlay.js by implementing custom overlay:// URL scheme
- **MessageBuilder Methods**: Added missing overlay_update() method to MessageBuilder class
- **Template Reload Logic**: Enhanced overlay update functionality to load specific template from form selection instead of just reloading current template
### Enhanced
- **Template Loading**: Prioritizes uploaded templates over built-in ones with same filename
- **File System Integration**: Qt Resource Collections (QRC) integration for JavaScript file embedding
- **Cross-Platform Paths**: Proper persistent storage paths for all operating systems
- **PyInstaller Compatibility**: Template system works correctly in compiled executable with persistent user data outside bundle
- **WebChannel Communication**: Enhanced Qt WebChannel integration between application and JavaScript overlay templates
- **Template Name Processing**: Automatic .html extension handling and proper template name normalization
### Technical Details
- Implemented OverlayUrlSchemeHandler for serving JavaScript files via custom overlay:// protocol
- Enhanced template loading with priority system (uploaded > built-in > default fallback)
- Added comprehensive file watcher monitoring both built-in and uploaded template directories
- Integrated Qt Resource system for embedding JavaScript files accessible via qrc:// URLs
- Enhanced template selection with automatic extension handling and proper fallback mechanisms
- Updated PyInstaller configuration to include templates directory and watchdog dependency
## [1.2.1] - 2025-08-20
### Fixed
......
......@@ -5,6 +5,7 @@ A cross-platform multimedia client application with video playback, web dashboar
## Features
- **PyQt Video Player**: Fullscreen video playback with dual overlay system (WebEngine and native Qt widgets)
- **Template Management System**: Upload, manage, and live-reload HTML overlay templates with persistent storage
- **Web Dashboard**: Authentication, user management, configuration interface, and admin system controls
- **REST API Client**: Configurable external API integration with automatic retry
- **Multi-threaded Architecture**: Four threads with Queue-based message passing and proper daemon thread management
......@@ -19,6 +20,17 @@ A cross-platform multimedia client application with video playback, web dashboar
## Recent Improvements
### Version 1.2.2 (August 2025)
-**Template Management System**: Complete HTML overlay template management with upload, delete, and real-time editing capabilities
-**Persistent Template Storage**: Cross-platform user template storage that survives PyInstaller executable updates
-**Template Priority System**: Uploaded templates automatically override built-in templates with same filename
-**JavaScript Resource Access**: Custom URL scheme handler enables uploaded templates to access overlay.js functionality
-**Live Template Switching**: Dashboard forms now switch to selected template instantly with overlay updates
-**Template Code Viewer**: View and copy template source code directly from web interface
-**File Watcher Integration**: Automatic template reloading when files change on disk
-**Enhanced Template UI**: Popup preview windows and comprehensive template management interface
### Version 1.2.1 (August 2025)
-**CRITICAL FIX: Video Display Resolved**: Completely fixed Qt video player black screen issue - video frames now render properly on all platforms
......@@ -144,9 +156,14 @@ mbetterc/
│ ├── core/ # Main loop and message handling
│ └── utils/ # Utility functions
├── assets/ # Static assets (images, templates)
├── templates/ # Video overlay templates
├── templates/ # Video overlay templates (built-in)
├── tests/ # Unit tests
└── docs/ # Documentation
# User Data Directories (Created automatically)
# Windows: %APPDATA%\MbetterClient\templates\
# macOS: ~/Library/Application Support/MbetterClient/templates/
# Linux: ~/.config/MbetterClient/templates/
```
### Message System
......@@ -189,10 +206,18 @@ Threads communicate via Python Queues with structured messages:
- `PUT /api/config/{section}` - Update configuration section
- `GET /api/config/{section}` - Get specific configuration section
#### Template Management
- `GET /api/templates` - List available overlay templates
- `POST /api/templates/upload` - Upload new template file
- `DELETE /api/templates/{name}` - Delete uploaded template
- `GET /api/templates/{name}/preview` - Preview template in browser
- `GET /api/templates/{name}/source` - Get template source code
#### Video Control
- `POST /api/video/control` - Control video playback (play, pause, stop, etc.)
- `GET /api/video/status` - Get current video player status
- `POST /api/video/upload` - Upload video file for playback
- `POST /api/overlay` - Update overlay content and switch templates
### Message Types
......@@ -210,6 +235,7 @@ Threads communicate via Python Queues with structured messages:
#### Configuration
- `CONFIG_UPDATE` - Configuration changed
- `TEMPLATE_CHANGE` - Video template changed
- `OVERLAY_UPDATE` - Overlay content updated
#### System Messages
- `SYSTEM_SHUTDOWN` - Application shutdown request
......
......@@ -124,6 +124,33 @@ def collect_data_files() -> List[tuple]:
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
# Include Qt player templates directory and all assets
templates_dir = project_root / 'mbetterclient' / 'qt_player' / 'templates'
if templates_dir.exists():
for file_path in templates_dir.rglob('*'):
if file_path.is_file():
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
print(f" 📁 Including templates directory: {templates_dir}")
# Specifically ensure JavaScript files in templates are included
js_files = list(templates_dir.rglob('*.js'))
css_files = list(templates_dir.rglob('*.css'))
print(f" 📄 Found {len(js_files)} JavaScript files in templates")
print(f" 🎨 Found {len(css_files)} CSS files in templates")
# Include any external JavaScript/CSS assets that templates might reference
# Look for common asset directories that templates might use
asset_patterns = ['assets', 'js', 'css', 'fonts', 'images']
for pattern in asset_patterns:
asset_dir = project_root / 'mbetterclient' / 'qt_player' / pattern
if asset_dir.exists():
for file_path in asset_dir.rglob('*'):
if file_path.is_file():
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
print(f" 📁 Including asset directory: {asset_dir}")
return data_files
......@@ -157,6 +184,11 @@ def collect_hidden_imports() -> List[str]:
# Logging
'loguru',
# File watching for template system
'watchdog',
'watchdog.observers',
'watchdog.events',
# Other dependencies
'packaging',
'pkg_resources',
......
......@@ -37,6 +37,7 @@ class MbetterClientApplication:
self.qt_player = None
self.web_dashboard = None
self.api_client = None
self.template_watcher = None
# Main loop thread
self._main_loop_thread: Optional[threading.Thread] = None
......@@ -171,6 +172,13 @@ class MbetterClientApplication:
try:
components_initialized = 0
# Initialize template watcher
if self._initialize_template_watcher():
components_initialized += 1
else:
logger.error("Template watcher initialization failed")
return False
# Initialize PyQt video player
if self.settings.enable_qt:
if self._initialize_qt_player():
......@@ -206,6 +214,63 @@ class MbetterClientApplication:
logger.error(f"Component initialization failed: {e}")
return False
def _initialize_template_watcher(self) -> bool:
"""Initialize template file watcher"""
try:
from ..qt_player.template_watcher import TemplateWatcher
# Built-in templates directory is in qt_player folder
builtin_templates_dir = Path(__file__).parent.parent / "qt_player" / "templates"
# Get persistent uploaded templates directory
uploaded_templates_dir = self._get_persistent_templates_dir()
uploaded_templates_dir.mkdir(parents=True, exist_ok=True)
self.template_watcher = TemplateWatcher(
message_bus=self.message_bus,
templates_dir=str(builtin_templates_dir),
uploaded_templates_dir=str(uploaded_templates_dir)
)
# Register with thread manager
self.thread_manager.register_component("template_watcher", self.template_watcher)
logger.info(f"Template watcher initialized - builtin: {builtin_templates_dir}, uploaded: {uploaded_templates_dir}")
return True
except Exception as e:
logger.error(f"Template watcher initialization failed: {e}")
return False
def _get_persistent_templates_dir(self) -> Path:
"""Get persistent templates directory for user uploads"""
try:
import platform
import os
from pathlib import Path
system = platform.system()
if system == "Windows":
# Use AppData/Roaming on Windows
app_data = os.getenv('APPDATA', os.path.expanduser('~'))
templates_dir = Path(app_data) / "MbetterClient" / "templates"
elif system == "Darwin": # macOS
# Use ~/Library/Application Support on macOS
templates_dir = Path.home() / "Library" / "Application Support" / "MbetterClient" / "templates"
else: # Linux and other Unix-like systems
# Use ~/.config on Linux
config_home = os.getenv('XDG_CONFIG_HOME', str(Path.home() / ".config"))
templates_dir = Path(config_home) / "MbetterClient" / "templates"
logger.debug(f"Persistent templates directory: {templates_dir}")
return templates_dir
except Exception as e:
logger.error(f"Failed to determine persistent templates directory: {e}")
# Fallback to local directory
return Path.cwd() / "user_templates"
def _initialize_qt_player(self) -> bool:
"""Initialize PyQt video player"""
try:
......
......@@ -233,8 +233,33 @@ class MessageBus:
def _deliver_to_queue(self, queue: Queue, message: Message) -> bool:
"""Deliver message to a specific queue"""
try:
# Special handling for high-frequency messages like VIDEO_PROGRESS
if message.type == MessageType.VIDEO_PROGRESS:
# Try to remove any existing VIDEO_PROGRESS message from the queue
try:
temp_queue = Queue()
removed = False
# Drain the queue, keeping all messages except VIDEO_PROGRESS
while True:
item = queue.get_nowait()
if item.type == MessageType.VIDEO_PROGRESS:
removed = True
else:
temp_queue.put(item)
except Empty:
pass
# Put back all non-VIDEO_PROGRESS messages
while not temp_queue.empty():
queue.put(temp_queue.get())
# Now add the new VIDEO_PROGRESS message
queue.put(message, block=False)
return True
# Priority handling - critical messages skip queue size limits
if message.priority >= 2:
elif message.priority >= 2:
queue.put(message, block=False)
else:
# Check queue size for normal messages
......@@ -420,6 +445,17 @@ class MessageBuilder:
}
)
@staticmethod
def overlay_update(sender: str, overlay_data: Dict[str, Any]) -> Message:
"""Create OVERLAY_UPDATE message"""
return Message(
type=MessageType.OVERLAY_UPDATE,
sender=sender,
data={
"overlay_data": overlay_data
}
)
@staticmethod
def api_request(sender: str, url: str, method: str = "GET",
headers: Optional[Dict[str, str]] = None,
......
"""
Custom URL scheme handler for serving overlay JavaScript files
"""
import logging
from pathlib import Path
from PyQt6.QtCore import QBuffer, QIODevice, QByteArray
from PyQt6.QtWebEngineCore import QWebEngineUrlRequestJob, QWebEngineUrlSchemeHandler
logger = logging.getLogger(__name__)
class OverlayUrlSchemeHandler(QWebEngineUrlSchemeHandler):
"""Custom URL scheme handler for overlay:// URLs"""
def __init__(self, parent=None):
super().__init__(parent)
logger.info("OverlayUrlSchemeHandler initialized")
def requestStarted(self, job: QWebEngineUrlRequestJob):
"""Handle URL requests for overlay:// scheme"""
try:
url = job.requestUrl()
path = url.path()
logger.debug(f"Overlay URL requested: {url.toString()}")
if path == "/overlay.js":
# Serve the overlay.js file
self._serve_overlay_js(job)
else:
# Unknown resource
logger.warning(f"Unknown overlay resource requested: {path}")
job.fail(QWebEngineUrlRequestJob.Error.UrlNotFound)
except Exception as e:
logger.error(f"Error handling overlay URL request: {e}")
job.fail(QWebEngineUrlRequestJob.Error.RequestFailed)
def _serve_overlay_js(self, job: QWebEngineUrlRequestJob):
"""Serve the overlay.js file content"""
try:
# Get overlay.js file from web dashboard static directory
overlay_js_path = Path(__file__).parent.parent / "web_dashboard" / "static" / "overlay.js"
if overlay_js_path.exists():
# Read the file content
with open(overlay_js_path, 'r', encoding='utf-8') as f:
content = f.read()
# Create QByteArray with content
data = QByteArray(content.encode('utf-8'))
# Create QBuffer for the data
buffer = QBuffer()
buffer.setData(data)
buffer.open(QIODevice.OpenModeFlag.ReadOnly)
# Reply with JavaScript content type
job.reply(b"application/javascript", buffer)
logger.debug("Served overlay.js successfully")
else:
logger.error(f"overlay.js file not found: {overlay_js_path}")
job.fail(QWebEngineUrlRequestJob.Error.UrlNotFound)
except Exception as e:
logger.error(f"Failed to serve overlay.js: {e}")
job.fail(QWebEngineUrlRequestJob.Error.RequestFailed)
\ No newline at end of file
......@@ -10,7 +10,7 @@ import json
import threading
import signal
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QStackedWidget
......@@ -27,11 +27,13 @@ from PyQt6.QtGui import (
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineProfile
from PyQt6.QtWebChannel import QWebChannel
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import QtConfig
from .overlay_url_handler import OverlayUrlSchemeHandler
logger = logging.getLogger(__name__)
......@@ -53,40 +55,119 @@ class OverlayWebChannel(QObject):
@pyqtSlot(str)
def updateTitle(self, title: str):
"""Update main title from JavaScript"""
# Clean data before emitting to prevent null emissions
if title is None or title == "":
logger.debug("Skipping null/empty title update")
return
with QMutexLocker(self.mutex):
self.overlay_data['title'] = title
self.dataUpdated.emit({'title': title})
logger.debug(f"Title updated: {title}")
cleaned_data = self._clean_data({'title': title})
if cleaned_data:
self.dataUpdated.emit(cleaned_data)
logger.debug(f"Title updated: {title}")
@pyqtSlot(str)
def updateSubtitle(self, subtitle: str):
"""Update subtitle from JavaScript"""
# Clean data before emitting to prevent null emissions
if subtitle is None or subtitle == "":
logger.debug("Skipping null/empty subtitle update")
return
with QMutexLocker(self.mutex):
self.overlay_data['subtitle'] = subtitle
self.dataUpdated.emit({'subtitle': subtitle})
logger.debug(f"Subtitle updated: {subtitle}")
cleaned_data = self._clean_data({'subtitle': subtitle})
if cleaned_data:
self.dataUpdated.emit(cleaned_data)
logger.debug(f"Subtitle updated: {subtitle}")
@pyqtSlot(bool)
def toggleStats(self, show: bool):
"""Toggle stats panel visibility"""
# Clean data before emitting to prevent null emissions
if show is None:
logger.debug("Skipping null show stats value")
return
with QMutexLocker(self.mutex):
self.overlay_data['showStats'] = show
self.dataUpdated.emit({'showStats': show})
logger.debug(f"Stats panel toggled: {show}")
cleaned_data = self._clean_data({'showStats': show})
if cleaned_data:
self.dataUpdated.emit(cleaned_data)
logger.debug(f"Stats panel toggled: {show}")
def send_data_update(self, data: Dict[str, Any]):
"""Send data update to JavaScript (thread-safe)"""
# Validate data before sending to prevent null emissions
if not data:
logger.warning("send_data_update called with null/empty data, skipping")
return
# Debug original data before cleaning
logger.debug(f"OverlayWebChannel received data: {data}, type: {type(data)}")
# Clean data to remove null/undefined values before sending to JavaScript
cleaned_data = self._clean_data(data)
if not cleaned_data:
logger.debug("All data properties were null/undefined, skipping JavaScript update")
return
# Debug what data is being sent to JavaScript
logger.debug(f"OverlayWebChannel sending to JavaScript: {cleaned_data}")
logger.debug(f"Data type: {type(cleaned_data)}, Data is dict: {isinstance(cleaned_data, dict)}")
with QMutexLocker(self.mutex):
self.overlay_data.update(data)
self.dataUpdated.emit(data)
self.overlay_data.update(cleaned_data)
# Add additional validation just before emit
if cleaned_data and isinstance(cleaned_data, dict) and any(v is not None for v in cleaned_data.values()):
self.dataUpdated.emit(cleaned_data)
logger.debug(f"Signal emitted successfully with data: {cleaned_data}")
else:
logger.warning(f"Prevented emission of invalid data: {cleaned_data}")
def _clean_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Clean data by removing null/undefined values before sending to JavaScript"""
cleaned_data = {}
for key, value in data.items():
# Skip null, undefined, or empty string values
if value is None or value == "":
logger.debug(f"OverlayWebChannel: Skipping null/empty property '{key}'")
continue
# Convert non-null values to appropriate types
if isinstance(value, str):
cleaned_data[key] = value
elif isinstance(value, bool):
cleaned_data[key] = value
elif isinstance(value, (int, float)):
cleaned_data[key] = str(value) # Convert numbers to strings for overlay
else:
# For other types, convert to string
cleaned_data[key] = str(value)
return cleaned_data
def send_position_update(self, position: float, duration: float):
"""Send playback position update to JavaScript (thread-safe)"""
# Validate position and duration before emitting
if position is None or duration is None:
logger.debug("Skipping null position/duration update")
return
self.positionChanged.emit(position, duration)
def send_video_info(self, info: Dict[str, Any]):
"""Send video information to JavaScript (thread-safe)"""
self.videoInfoChanged.emit(info)
# Validate info before emitting
if not info:
logger.debug("Skipping null/empty video info update")
return
cleaned_info = self._clean_data(info)
if cleaned_info:
self.videoInfoChanged.emit(cleaned_info)
else:
logger.debug("Video info contained only null/undefined values, skipping")
class VideoProcessingWorker(QRunnable):
......@@ -149,8 +230,21 @@ class OverlayWebView(QWebEngineView):
super().__init__(parent)
self.web_channel = None
self.overlay_channel = None
self.current_template = "default.html"
# Built-in templates directory (bundled with app)
self.builtin_templates_dir = Path(__file__).parent / "templates"
# Persistent uploaded templates directory (user data)
self.uploaded_templates_dir = self._get_persistent_templates_dir()
self.uploaded_templates_dir.mkdir(parents=True, exist_ok=True)
# Primary templates directory for backwards compatibility
self.templates_dir = self.builtin_templates_dir
self.setup_web_view()
logger.info("OverlayWebView initialized")
self._setup_custom_scheme()
logger.info(f"OverlayWebView initialized - builtin: {self.builtin_templates_dir}, uploaded: {self.uploaded_templates_dir}")
def setup_web_view(self):
"""Setup web view with proper transparency for overlay"""
......@@ -176,34 +270,138 @@ class OverlayWebView(QWebEngineView):
self.web_channel.registerObject("overlay", self.overlay_channel)
page.setWebChannel(self.web_channel)
# Load overlay HTML (handle both development and PyInstaller bundle modes)
overlay_path = self._get_overlay_path()
if overlay_path and overlay_path.exists():
self.load(QUrl.fromLocalFile(str(overlay_path)))
logger.info(f"Loaded overlay HTML: {overlay_path}")
else:
logger.error(f"Overlay HTML not found: {overlay_path}")
# Load fallback minimal overlay
self._load_fallback_overlay()
# Load default template
self.load_template(self.current_template)
def _setup_custom_scheme(self):
"""Setup custom URL scheme handler for overlay resources"""
try:
# Get the page's profile
profile = self.page().profile()
# Create and install URL scheme handler
self.scheme_handler = OverlayUrlSchemeHandler(self)
profile.installUrlSchemeHandler(b"overlay", self.scheme_handler)
logger.info("Custom overlay:// URL scheme handler installed successfully")
except Exception as e:
logger.error(f"Failed to setup custom URL scheme: {e}")
def _get_overlay_path(self) -> Optional[Path]:
"""Get overlay HTML path, handling PyInstaller bundle mode"""
def load_template(self, template_name: str):
"""Load a specific template file, prioritizing uploaded templates"""
try:
# Check if running as PyInstaller bundle
if hasattr(sys, '_MEIPASS'):
# Running as bundled executable
bundle_path = Path(sys._MEIPASS)
overlay_path = bundle_path / "mbetterclient" / "qt_player" / "overlay.html"
logger.debug(f"Bundle mode - checking overlay at: {overlay_path}")
return overlay_path
# If no template name provided, use default
if not template_name:
template_name = "default.html"
# Ensure .html extension
if not template_name.endswith('.html'):
template_name += '.html'
# First try uploaded templates directory (user uploads take priority)
template_path = self.uploaded_templates_dir / template_name
template_source = "uploaded"
# If not found in uploaded, try built-in templates
if not template_path.exists():
template_path = self.builtin_templates_dir / template_name
template_source = "builtin"
# If still not found, fallback to default.html in built-in templates
if not template_path.exists():
default_template_path = self.builtin_templates_dir / "default.html"
if default_template_path.exists():
template_path = default_template_path
template_name = "default.html"
template_source = "builtin"
logger.warning(f"Template {template_name} not found, using default.html")
else:
logger.error(f"No template found: {template_name}, and default.html missing")
# Load fallback minimal overlay
self._load_fallback_overlay()
return
if template_path and template_path.exists():
self.load(QUrl.fromLocalFile(str(template_path)))
self.current_template = template_name
logger.info(f"Loaded template: {template_path} (source: {template_source})")
else:
# Running in development mode
overlay_path = Path(__file__).parent / "overlay.html"
logger.debug(f"Development mode - checking overlay at: {overlay_path}")
return overlay_path
logger.error(f"No template found: {template_name}")
# Load fallback minimal overlay
self._load_fallback_overlay()
except Exception as e:
logger.error(f"Failed to load template {template_name}: {e}")
self._load_fallback_overlay()
def reload_current_template(self):
"""Reload the current template"""
self.load_template(self.current_template)
def get_available_templates(self) -> List[str]:
"""Get list of available template files from both directories"""
try:
templates = set()
# Get built-in templates
if self.builtin_templates_dir.exists():
builtin_templates = [f.name for f in self.builtin_templates_dir.glob("*.html")]
templates.update(builtin_templates)
# Get uploaded templates (these override built-in ones with same name)
if self.uploaded_templates_dir.exists():
uploaded_templates = [f.name for f in self.uploaded_templates_dir.glob("*.html")]
templates.update(uploaded_templates)
template_list = sorted(list(templates))
if not template_list:
template_list = ["default.html"]
return template_list
except Exception as e:
logger.error(f"Failed to get available templates: {e}")
return ["default.html"]
def _get_persistent_templates_dir(self) -> Path:
"""Get persistent templates directory for user uploads"""
try:
import platform
import os
system = platform.system()
if system == "Windows":
# Use AppData/Roaming on Windows
app_data = os.getenv('APPDATA', os.path.expanduser('~'))
templates_dir = Path(app_data) / "MbetterClient" / "templates"
elif system == "Darwin": # macOS
# Use ~/Library/Application Support on macOS
templates_dir = Path.home() / "Library" / "Application Support" / "MbetterClient" / "templates"
else: # Linux and other Unix-like systems
# Use ~/.config on Linux
config_home = os.getenv('XDG_CONFIG_HOME', str(Path.home() / ".config"))
templates_dir = Path(config_home) / "MbetterClient" / "templates"
logger.debug(f"Persistent templates directory: {templates_dir}")
return templates_dir
except Exception as e:
logger.error(f"Failed to determine overlay path: {e}")
logger.error(f"Failed to determine persistent templates directory: {e}")
# Fallback to local directory
return Path.cwd() / "user_templates"
def _get_default_template_path(self) -> Optional[Path]:
"""Get default template path from templates directory"""
try:
# Always use default.html from built-in templates directory
default_template_path = self.builtin_templates_dir / "default.html"
logger.debug(f"Default template path: {default_template_path}")
return default_template_path
except Exception as e:
logger.error(f"Failed to determine default template path: {e}")
return None
def _load_fallback_overlay(self):
......@@ -253,6 +451,11 @@ class OverlayWebView(QWebEngineView):
def update_overlay_data(self, data: Dict[str, Any]):
"""Update overlay with new data"""
# Validate data before sending to prevent null emissions
if not data:
logger.warning("update_overlay_data called with null/empty data, skipping")
return
if self.overlay_channel:
self.overlay_channel.send_data_update(data)
......@@ -850,8 +1053,8 @@ class PlayerWindow(QMainWindow):
if file_path:
self.play_video(file_path)
def play_video(self, file_path: str, template_data: Dict[str, Any] = None):
"""Play video file with optional overlay data"""
def play_video(self, file_path: str, template_data: Dict[str, Any] = None, template_name: str = None):
"""Play video file with optional overlay data and template"""
try:
logger.info(f"PlayerWindow.play_video() called with: {file_path}")
logger.info(f"Media player state before play: {self.media_player.playbackState()}")
......@@ -898,6 +1101,15 @@ class PlayerWindow(QMainWindow):
self.media_player.setSource(url)
logger.info(f"Media player source set to: {url.toString()}")
# Load specified template or reload current template when playing a video
if hasattr(self, 'window_overlay') and isinstance(self.window_overlay, OverlayWebView):
if template_name:
self.window_overlay.load_template(template_name)
logger.info(f"Loaded template '{template_name}' for video playback")
else:
self.window_overlay.reload_current_template()
logger.info("Reloaded current overlay template for video playback")
# Update overlay with video info using safe method
overlay_data = template_data or {}
overlay_data.update({
......@@ -1097,8 +1309,10 @@ class PlayerWindow(QMainWindow):
current_time = time.strftime("%H:%M:%S")
if hasattr(self, 'window_overlay'):
overlay_view = self.window_overlay
# Update overlay safely - handles both native and WebEngine
self._update_overlay_safe(overlay_view, {'currentTime': current_time})
# Only update if we have valid overlay data
time_data = {'currentTime': current_time}
if time_data and current_time: # Ensure we have valid data
self._update_overlay_safe(overlay_view, time_data)
except Exception as e:
logger.error(f"Periodic overlay update failed: {e}")
......@@ -1198,18 +1412,69 @@ class PlayerWindow(QMainWindow):
"""Check if this is a native overlay"""
return isinstance(overlay_view, NativeOverlayWidget)
def _clean_overlay_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Clean overlay data by removing null/undefined values"""
cleaned_data = {}
# Define valid overlay properties and their expected types
valid_properties = {
'title': str,
'subtitle': str,
'ticker': str,
'currentTime': str,
'showStats': bool,
'customCSS': str
}
for key, value in data.items():
# Skip null, undefined, or empty string values
if value is None or value == "":
logger.debug(f"Skipping null/empty property '{key}' in overlay data")
continue
# Validate property type if it's a known property
if key in valid_properties:
expected_type = valid_properties[key]
if not isinstance(value, expected_type):
logger.debug(f"Converting property '{key}' from {type(value)} to {expected_type}")
try:
# Convert to expected type
if expected_type == str:
cleaned_data[key] = str(value)
elif expected_type == bool:
cleaned_data[key] = bool(value)
else:
cleaned_data[key] = value
except (ValueError, TypeError):
logger.warning(f"Failed to convert property '{key}' to {expected_type}, skipping")
continue
else:
cleaned_data[key] = value
else:
# Unknown property, pass through if not null
cleaned_data[key] = value
logger.debug(f"Cleaned overlay data: {cleaned_data}")
return cleaned_data
def _update_overlay_safe(self, overlay_view, data):
"""Update overlay data safely, handling both native and WebEngine overlays"""
try:
# Clean data before sending to prevent null property issues
cleaned_data = self._clean_overlay_data(data)
if not cleaned_data:
logger.debug("No valid data to send to overlay after cleaning")
return False
if self._is_native_overlay(overlay_view):
# Native overlay - always ready, update immediately
overlay_view.update_overlay_data(data)
overlay_view.update_overlay_data(cleaned_data)
logger.debug("Native overlay updated successfully")
return True
elif isinstance(overlay_view, OverlayWebView):
# WebEngine overlay - check readiness first
if self._is_webengine_ready(overlay_view):
overlay_view.update_overlay_data(data)
overlay_view.update_overlay_data(cleaned_data)
logger.debug("WebEngine overlay updated successfully")
return True
else:
......@@ -1509,15 +1774,76 @@ class QtVideoPlayer(QObject):
logger.debug(f"WebEngine readiness check failed: {e}")
return False
def _clean_overlay_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Clean overlay data by removing null/undefined values"""
cleaned_data = {}
# Define valid overlay properties and their expected types
valid_properties = {
'title': str,
'subtitle': str,
'ticker': str,
'currentTime': str,
'showStats': bool,
'customCSS': str
}
for key, value in data.items():
# Skip null, undefined, or empty string values
if value is None or value == "":
logger.debug(f"Skipping null/empty property '{key}' in overlay data")
continue
# Validate property type if it's a known property
if key in valid_properties:
expected_type = valid_properties[key]
if not isinstance(value, expected_type):
logger.debug(f"Converting property '{key}' from {type(value)} to {expected_type}")
try:
# Convert to expected type
if expected_type == str:
cleaned_data[key] = str(value)
elif expected_type == bool:
cleaned_data[key] = bool(value)
else:
cleaned_data[key] = value
except (ValueError, TypeError):
logger.warning(f"Failed to convert property '{key}' to {expected_type}, skipping")
continue
else:
cleaned_data[key] = value
else:
# Unknown property, pass through if not null
cleaned_data[key] = value
logger.debug(f"Cleaned overlay data: {cleaned_data}")
return cleaned_data
def _send_safe_overlay_update(self, overlay_view, data):
"""Send overlay update with additional safety checks"""
try:
if not self._is_webengine_ready(overlay_view):
logger.debug("WebEngine lost readiness, skipping update")
# Clean data before sending to prevent null property issues
cleaned_data = self._clean_overlay_data(data)
if not cleaned_data:
logger.debug("No valid data to send to overlay after cleaning")
return
overlay_view.update_overlay_data(data)
logger.info("Default WebEngine overlay loaded successfully")
if hasattr(self, 'window') and self.window:
# Use the PlayerWindow's _update_overlay_safe method for consistency
self.window._update_overlay_safe(overlay_view, cleaned_data)
logger.info("Default WebEngine overlay loaded successfully")
else:
# Fallback to direct update if no window available
if isinstance(overlay_view, OverlayWebView):
if self._is_webengine_ready(overlay_view):
overlay_view.update_overlay_data(cleaned_data)
logger.info("WebEngine overlay updated successfully")
else:
logger.debug("WebEngine overlay not ready, skipping update")
else:
# Native overlay
overlay_view.update_overlay_data(cleaned_data)
logger.info("Native overlay updated successfully")
except Exception as e:
logger.error(f"Failed to send safe overlay update: {e}")
......@@ -1743,11 +2069,11 @@ class QtVideoPlayer(QObject):
try:
file_path = message.data.get("file_path")
template_data = message.data.get("overlay_data", {})
template = message.data.get("template", "news_template")
template_name = message.data.get("template") # Extract template name from message
logger.info(f"VIDEO_PLAY message received from {message.sender}")
logger.info(f"File path: {file_path}")
logger.info(f"Template: {template}")
logger.info(f"Template name: {template_name}")
logger.info(f"Overlay data: {template_data}")
if not file_path:
......@@ -1767,8 +2093,8 @@ class QtVideoPlayer(QObject):
logger.info(f"Handler is main thread: {threading.current_thread() is threading.main_thread()}")
logger.info("CALLING play_video() DIRECTLY on Qt main thread - no cross-thread issues!")
# Direct call - we're already on the main thread!
self.window.play_video(file_path, template_data)
# Direct call - we're already on the main thread! Pass template name
self.window.play_video(file_path, template_data, template_name)
logger.info("play_video() called successfully on main thread")
except Exception as e:
......@@ -1840,12 +2166,36 @@ class QtVideoPlayer(QObject):
def _handle_template_change(self, message: Message):
"""Handle template change message - now running on Qt main thread"""
try:
template_name = message.data.get("template_name", "")
template_data = message.data.get("template_data", {})
reload_template = template_data.get("reload_template", False)
load_specific_template = template_data.get("load_specific_template", "")
if self.window and template_data and hasattr(self.window, 'window_overlay'):
# Direct call - we're already on the main thread!
if self.window and hasattr(self.window, 'window_overlay'):
overlay_view = self.window.window_overlay
self.window._update_overlay_safe(overlay_view, template_data)
# Load specific template if requested and using WebEngine overlay
if load_specific_template and isinstance(overlay_view, OverlayWebView):
logger.info(f"Loading specific template: {load_specific_template}")
overlay_view.load_template(load_specific_template)
# Otherwise reload current template if requested and using WebEngine overlay
elif reload_template and isinstance(overlay_view, OverlayWebView):
logger.info("Reloading current template due to template_change message")
overlay_view.reload_current_template()
# Update overlay data if provided (excluding template control flags)
if template_data:
# Remove template control flags from data to be sent to overlay
data_to_send = {k: v for k, v in template_data.items()
if k not in ['reload_template', 'load_specific_template']}
if data_to_send:
# Validate and clean template_data before sending to overlay
cleaned_data = self._clean_overlay_data(data_to_send)
if cleaned_data: # Only send if we have valid data after cleaning
self.window._update_overlay_safe(overlay_view, cleaned_data)
else:
logger.debug("Template data contained only null/undefined values, skipping update")
except Exception as e:
logger.error(f"Failed to handle template change: {e}")
......@@ -1856,9 +2206,13 @@ class QtVideoPlayer(QObject):
overlay_data = message.data.get("overlay_data", {})
if self.window and overlay_data and hasattr(self.window, 'window_overlay'):
# Direct call - we're already on the main thread!
overlay_view = self.window.window_overlay
self.window._update_overlay_safe(overlay_view, overlay_data)
# Validate and clean overlay_data before sending to overlay
cleaned_data = self._clean_overlay_data(overlay_data)
if cleaned_data: # Only send if we have valid data after cleaning
overlay_view = self.window.window_overlay
self.window._update_overlay_safe(overlay_view, cleaned_data)
else:
logger.debug("Overlay data contained only null/undefined values, skipping update")
except Exception as e:
logger.error(f"Failed to handle overlay update: {e}")
......
......@@ -7,7 +7,7 @@ import time
import logging
import json
from pathlib import Path
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QStackedWidget
......@@ -24,11 +24,13 @@ from PyQt6.QtGui import (
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineProfile
from PyQt6.QtWebChannel import QWebChannel
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import QtConfig
from .overlay_url_handler import OverlayUrlSchemeHandler
logger = logging.getLogger(__name__)
......@@ -146,8 +148,21 @@ class OverlayWebView(QWebEngineView):
super().__init__(parent)
self.web_channel = None
self.overlay_channel = None
self.current_template = "default.html"
# Built-in templates directory (bundled with app)
self.builtin_templates_dir = Path(__file__).parent / "templates"
# Persistent uploaded templates directory (user data)
self.uploaded_templates_dir = self._get_persistent_templates_dir()
self.uploaded_templates_dir.mkdir(parents=True, exist_ok=True)
# Primary templates directory for backwards compatibility
self.templates_dir = self.builtin_templates_dir
self.setup_web_view()
logger.info("OverlayWebView initialized")
self._setup_custom_scheme()
logger.info(f"OverlayWebView initialized - builtin: {self.builtin_templates_dir}, uploaded: {self.uploaded_templates_dir}")
def setup_web_view(self):
"""Setup web view for transparent overlays"""
......@@ -165,13 +180,122 @@ class OverlayWebView(QWebEngineView):
self.web_channel.registerObject("overlay", self.overlay_channel)
page.setWebChannel(self.web_channel)
# Load overlay HTML
overlay_path = Path(__file__).parent / "overlay.html"
if overlay_path.exists():
self.load(QUrl.fromLocalFile(str(overlay_path)))
logger.info(f"Loaded overlay HTML: {overlay_path}")
else:
logger.error(f"Overlay HTML not found: {overlay_path}")
# Load default template
self.load_template(self.current_template)
def _setup_custom_scheme(self):
"""Setup custom URL scheme handler for overlay resources"""
try:
# Get the page's profile
profile = self.page().profile()
# Create and install URL scheme handler
self.scheme_handler = OverlayUrlSchemeHandler(self)
profile.installUrlSchemeHandler(b"overlay", self.scheme_handler)
logger.info("Custom overlay:// URL scheme handler installed successfully")
except Exception as e:
logger.error(f"Failed to setup custom URL scheme: {e}")
def load_template(self, template_name: str):
"""Load a specific template file, prioritizing uploaded templates"""
try:
# If no template name provided, use default
if not template_name:
template_name = "default.html"
# Ensure .html extension
if not template_name.endswith('.html'):
template_name += '.html'
# First try uploaded templates directory (user uploads take priority)
template_path = self.uploaded_templates_dir / template_name
template_source = "uploaded"
# If not found in uploaded, try built-in templates
if not template_path.exists():
template_path = self.builtin_templates_dir / template_name
template_source = "builtin"
# If still not found, fallback to default.html in built-in templates
if not template_path.exists():
default_template_path = self.builtin_templates_dir / "default.html"
if default_template_path.exists():
template_path = default_template_path
template_name = "default.html"
template_source = "builtin"
logger.warning(f"Template {template_name} not found, using default.html")
else:
logger.error(f"No template found: {template_name}, and default.html missing")
return
if template_path.exists():
self.load(QUrl.fromLocalFile(str(template_path)))
self.current_template = template_name
logger.info(f"Loaded template: {template_path} (source: {template_source})")
else:
logger.error(f"No template found: {template_name}")
except Exception as e:
logger.error(f"Failed to load template {template_name}: {e}")
def reload_current_template(self):
"""Reload the current template"""
self.load_template(self.current_template)
def get_available_templates(self) -> List[str]:
"""Get list of available template files from both directories"""
try:
templates = set()
# Get built-in templates
if self.builtin_templates_dir.exists():
builtin_templates = [f.name for f in self.builtin_templates_dir.glob("*.html")]
templates.update(builtin_templates)
# Get uploaded templates (these override built-in ones with same name)
if self.uploaded_templates_dir.exists():
uploaded_templates = [f.name for f in self.uploaded_templates_dir.glob("*.html")]
templates.update(uploaded_templates)
template_list = sorted(list(templates))
if not template_list:
template_list = ["default.html"]
return template_list
except Exception as e:
logger.error(f"Failed to get available templates: {e}")
return ["default.html"]
def _get_persistent_templates_dir(self) -> Path:
"""Get persistent templates directory for user uploads"""
try:
import platform
import os
system = platform.system()
if system == "Windows":
# Use AppData/Roaming on Windows
app_data = os.getenv('APPDATA', os.path.expanduser('~'))
templates_dir = Path(app_data) / "MbetterClient" / "templates"
elif system == "Darwin": # macOS
# Use ~/Library/Application Support on macOS
templates_dir = Path.home() / "Library" / "Application Support" / "MbetterClient" / "templates"
else: # Linux and other Unix-like systems
# Use ~/.config on Linux
config_home = os.getenv('XDG_CONFIG_HOME', str(Path.home() / ".config"))
templates_dir = Path(config_home) / "MbetterClient" / "templates"
logger.debug(f"Persistent templates directory: {templates_dir}")
return templates_dir
except Exception as e:
logger.error(f"Failed to determine persistent templates directory: {e}")
# Fallback to local directory
return Path.cwd() / "user_templates"
def get_overlay_channel(self) -> OverlayWebChannel:
"""Get the overlay communication channel"""
......@@ -501,13 +625,22 @@ class PlayerWindow(QMainWindow):
if file_path:
self.play_video(file_path)
def play_video(self, file_path: str, template_data: Dict[str, Any] = None):
"""Play video file with optional overlay data"""
def play_video(self, file_path: str, template_data: Dict[str, Any] = None, template_name: str = None):
"""Play video file with optional overlay data and template"""
try:
with QMutexLocker(self.mutex):
url = QUrl.fromLocalFile(str(Path(file_path).absolute()))
self.media_player.setSource(url)
# Load specified template or reload current template when playing a video
overlay_view = self.video_widget.get_overlay_view()
if template_name:
overlay_view.load_template(template_name)
logger.info(f"Loaded template '{template_name}' for video playback")
else:
overlay_view.reload_current_template()
logger.info("Reloaded current overlay template for video playback")
# Update overlay with video info
overlay_data = template_data or {}
overlay_data.update({
......@@ -515,14 +648,14 @@ class PlayerWindow(QMainWindow):
'subtitle': 'MbetterClient PyQt6 Player'
})
self.video_widget.get_overlay_view().update_overlay_data(overlay_data)
overlay_view.update_overlay_data(overlay_data)
if self.settings.auto_play:
self.media_player.play()
# Start background metadata extraction
worker = VideoProcessingWorker(
"metadata_extraction",
"metadata_extraction",
{"file_path": file_path},
self.on_metadata_extracted
)
......@@ -835,15 +968,17 @@ class Qt6VideoPlayer(ThreadedComponent):
try:
file_path = message.data.get("file_path")
template_data = message.data.get("overlay_data", {})
template_name = message.data.get("template") # Extract template name from message
if not file_path:
logger.error("No file path provided for video play")
return
logger.info(f"Playing video: {file_path}")
logger.info(f"Template name: {template_name}")
if self.window:
self.window.play_video(file_path, template_data)
self.window.play_video(file_path, template_data, template_name)
except Exception as e:
logger.error(f"Failed to handle video play: {e}")
......@@ -880,10 +1015,20 @@ class Qt6VideoPlayer(ThreadedComponent):
"""Handle template change message"""
try:
template_data = message.data.get("template_data", {})
template_name = template_data.get("template_name", "")
change_type = template_data.get("change_type", "")
if self.window and template_data:
if self.window:
overlay_view = self.window.video_widget.get_overlay_view()
overlay_view.update_overlay_data(template_data)
# Reload template if it's the current one being used
if template_name == overlay_view.current_template:
logger.info(f"Reloading template {template_name} due to {change_type}")
overlay_view.reload_current_template()
# Update overlay data if provided
if template_data:
overlay_view.update_overlay_data(template_data)
except Exception as e:
logger.error(f"Failed to handle template change: {e}")
......
<RCC>
<qresource prefix="/js">
<file alias="overlay.js">../web_dashboard/static/overlay.js</file>
</qresource>
</RCC>
\ No newline at end of file
"""
Template file watcher for monitoring HTML template changes
"""
import os
import time
import logging
import platform
from pathlib import Path
from typing import Dict, Any, Optional, List
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, MessageBuilder, MessageType
logger = logging.getLogger(__name__)
class TemplateChangeHandler(FileSystemEventHandler):
"""Handle template file system events"""
def __init__(self, message_bus: MessageBus, component_name: str):
super().__init__()
self.message_bus = message_bus
self.component_name = component_name
self.templates_cache = {}
logger.info("TemplateChangeHandler initialized")
def on_modified(self, event):
"""Handle file modification events"""
if not event.is_directory and event.src_path.endswith('.html'):
self._handle_template_change(event.src_path, 'modified')
def on_created(self, event):
"""Handle file creation events"""
if not event.is_directory and event.src_path.endswith('.html'):
self._handle_template_change(event.src_path, 'created')
def on_deleted(self, event):
"""Handle file deletion events"""
if not event.is_directory and event.src_path.endswith('.html'):
self._handle_template_change(event.src_path, 'deleted')
def on_moved(self, event):
"""Handle file move/rename events"""
if not event.is_directory:
if event.src_path.endswith('.html'):
self._handle_template_change(event.src_path, 'deleted')
if event.dest_path.endswith('.html'):
self._handle_template_change(event.dest_path, 'created')
def _handle_template_change(self, file_path: str, change_type: str):
"""Handle template file changes"""
try:
template_name = Path(file_path).name
# Read template content if file exists
template_content = None
if change_type != 'deleted' and os.path.exists(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
template_content = f.read()
except Exception as e:
logger.error(f"Failed to read template {file_path}: {e}")
return
# Prepare template data
template_data = {
'template_name': template_name,
'file_path': file_path,
'change_type': change_type,
'timestamp': time.time()
}
if template_content:
template_data['content'] = template_content
# Send TEMPLATE_CHANGE message
message = MessageBuilder.template_change(
sender=self.component_name,
template_name=template_name,
template_data=template_data
)
self.message_bus.publish(message, broadcast=True)
logger.info(f"Template {change_type}: {template_name}")
except Exception as e:
logger.error(f"Failed to handle template change: {e}")
class TemplateWatcher(ThreadedComponent):
"""File system watcher for HTML templates"""
def __init__(self, message_bus: MessageBus, templates_dir: str, uploaded_templates_dir: str = None):
super().__init__("template_watcher", message_bus)
self.builtin_templates_dir = Path(templates_dir)
self.uploaded_templates_dir = Path(uploaded_templates_dir) if uploaded_templates_dir else self._get_persistent_templates_dir()
self.observer: Optional[Observer] = None
self.event_handlers: List[TemplateChangeHandler] = []
# Ensure directories exist
self.builtin_templates_dir.mkdir(parents=True, exist_ok=True)
self.uploaded_templates_dir.mkdir(parents=True, exist_ok=True)
# Backwards compatibility
self.templates_dir = self.builtin_templates_dir
# Register message queue
self.message_queue = self.message_bus.register_component(self.name)
logger.info(f"TemplateWatcher initialized - builtin: {self.builtin_templates_dir}, uploaded: {self.uploaded_templates_dir}")
def initialize(self) -> bool:
"""Initialize the file watcher for both directories"""
try:
# Create observer
self.observer = Observer()
# Create event handler for built-in templates
builtin_handler = TemplateChangeHandler(self.message_bus, self.name)
self.event_handlers.append(builtin_handler)
self.observer.schedule(
builtin_handler,
str(self.builtin_templates_dir),
recursive=False
)
# Create event handler for uploaded templates
uploaded_handler = TemplateChangeHandler(self.message_bus, self.name)
self.event_handlers.append(uploaded_handler)
self.observer.schedule(
uploaded_handler,
str(self.uploaded_templates_dir),
recursive=False
)
logger.info("TemplateWatcher initialized successfully for both directories")
return True
except Exception as e:
logger.error(f"TemplateWatcher initialization failed: {e}")
return False
def run(self):
"""Main run loop"""
try:
logger.info("TemplateWatcher thread started")
# Start the file system observer
if self.observer:
self.observer.start()
logger.info(f"Started watching builtin templates: {self.builtin_templates_dir}")
logger.info(f"Started watching uploaded templates: {self.uploaded_templates_dir}")
# Send ready status
ready_message = MessageBuilder.system_status(
sender=self.name,
status="ready",
details={
"builtin_templates_dir": str(self.builtin_templates_dir),
"uploaded_templates_dir": str(self.uploaded_templates_dir)
}
)
self.message_bus.publish(ready_message)
# Scan existing templates on startup from both directories
self._scan_existing_templates()
# Message processing loop
while self.running:
try:
# Process messages
message = self.message_bus.get_message(self.name, timeout=1.0)
if message:
self._process_message(message)
# Update heartbeat
self.heartbeat()
time.sleep(0.1)
except Exception as e:
logger.error(f"TemplateWatcher run loop error: {e}")
time.sleep(1.0)
except Exception as e:
logger.error(f"TemplateWatcher run failed: {e}")
finally:
# Stop observer
if self.observer:
self.observer.stop()
self.observer.join()
logger.info("TemplateWatcher thread ended")
def shutdown(self):
"""Shutdown template watcher"""
try:
logger.info("Shutting down TemplateWatcher...")
if self.observer:
self.observer.stop()
self.observer.join()
self.observer = None
self.event_handler = None
except Exception as e:
logger.error(f"TemplateWatcher shutdown error: {e}")
def _process_message(self, message):
"""Process received message"""
try:
# Currently no specific messages to handle
# Future: Could handle requests to reload templates, change watch directory, etc.
pass
except Exception as e:
logger.error(f"Failed to process message: {e}")
def _scan_existing_templates(self):
"""Scan and report existing templates on startup from both directories"""
try:
# Scan built-in templates
if self.builtin_templates_dir.exists():
builtin_files = list(self.builtin_templates_dir.glob("*.html"))
logger.info(f"Found {len(builtin_files)} built-in template(s)")
self._scan_template_directory(builtin_files, "builtin")
# Scan uploaded templates
if self.uploaded_templates_dir.exists():
uploaded_files = list(self.uploaded_templates_dir.glob("*.html"))
logger.info(f"Found {len(uploaded_files)} uploaded template(s)")
self._scan_template_directory(uploaded_files, "uploaded")
except Exception as e:
logger.error(f"Failed to scan existing templates: {e}")
def _scan_template_directory(self, html_files: List[Path], source_type: str):
"""Scan templates in a specific directory"""
for template_file in html_files:
try:
# Read template content
with open(template_file, 'r', encoding='utf-8') as f:
content = f.read()
# Send template change message for existing templates
template_data = {
'template_name': template_file.name,
'file_path': str(template_file),
'change_type': 'existing',
'source_type': source_type,
'content': content,
'timestamp': time.time()
}
message = MessageBuilder.template_change(
sender=self.name,
template_name=template_file.name,
template_data=template_data
)
self.message_bus.publish(message, broadcast=True)
logger.debug(f"Reported existing {source_type} template: {template_file.name}")
except Exception as e:
logger.error(f"Failed to process existing template {template_file}: {e}")
def get_available_templates(self) -> List[str]:
"""Get list of available template names from both directories"""
try:
templates = set()
# Get built-in templates
if self.builtin_templates_dir.exists():
builtin_templates = [f.name for f in self.builtin_templates_dir.glob("*.html")]
templates.update(builtin_templates)
# Get uploaded templates (these override built-in ones with same name)
if self.uploaded_templates_dir.exists():
uploaded_templates = [f.name for f in self.uploaded_templates_dir.glob("*.html")]
templates.update(uploaded_templates)
return sorted(list(templates))
except Exception as e:
logger.error(f"Failed to get available templates: {e}")
return []
def get_template_content(self, template_name: str) -> Optional[str]:
"""Get content of a specific template, prioritizing uploaded templates"""
try:
# First try uploaded templates (user uploads take priority)
template_path = self.uploaded_templates_dir / template_name
# If not found in uploaded, try built-in templates
if not template_path.exists():
template_path = self.builtin_templates_dir / template_name
if template_path.exists() and template_path.suffix == '.html':
with open(template_path, 'r', encoding='utf-8') as f:
return f.read()
else:
logger.warning(f"Template not found: {template_name}")
return None
except Exception as e:
logger.error(f"Failed to get template content for {template_name}: {e}")
return None
def _get_persistent_templates_dir(self) -> Path:
"""Get persistent templates directory for user uploads"""
try:
system = platform.system()
if system == "Windows":
# Use AppData/Roaming on Windows
app_data = os.getenv('APPDATA', os.path.expanduser('~'))
templates_dir = Path(app_data) / "MbetterClient" / "templates"
elif system == "Darwin": # macOS
# Use ~/Library/Application Support on macOS
templates_dir = Path.home() / "Library" / "Application Support" / "MbetterClient" / "templates"
else: # Linux and other Unix-like systems
# Use ~/.config on Linux
config_home = os.getenv('XDG_CONFIG_HOME', str(Path.home() / ".config"))
templates_dir = Path(config_home) / "MbetterClient" / "templates"
logger.debug(f"Persistent templates directory: {templates_dir}")
return templates_dir
except Exception as e:
logger.error(f"Failed to determine persistent templates directory: {e}")
# Fallback to local directory
return Path.cwd() / "user_templates"
\ No newline at end of file
......@@ -292,458 +292,18 @@
<div class="progress-bar" id="progressBar" style="width: 0%;"></div>
</div>
<!--
IMPORTANT: When creating or editing custom templates, always maintain these two script tags:
1. qrc:///qtwebchannel/qwebchannel.js - Required for Qt WebChannel communication
2. overlay://overlay.js - Required for overlay functionality and data updates
These scripts enable communication between the Qt application and the overlay template.
Without them, the template will not receive data updates or function properly.
NOTE: When editing this template or creating new ones, never remove these script sources!
The overlay:// custom scheme ensures JavaScript files work for both built-in and uploaded templates.
-->
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script>
class OverlayManager {
constructor() {
this.channel = null;
this.overlayData = {};
this.canvas = null;
this.ctx = null;
this.animationFrame = null;
this.webChannelReady = false;
this.pendingUpdates = [];
// Wait for DOM to be fully loaded before accessing elements
this.waitForDOM(() => {
this.canvas = document.getElementById('canvasOverlay');
if (this.canvas) {
this.ctx = this.canvas.getContext('2d');
// Resize canvas to match window
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
// Start canvas animations
this.startCanvasAnimations();
}
// Initialize WebChannel after DOM is ready
this.initWebChannel();
});
console.log('OverlayManager constructor called');
}
waitForDOM(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback);
} else {
callback();
}
}
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
initWebChannel() {
try {
if (typeof qt === 'undefined' || !qt.webChannelTransport) {
console.log('WebChannel transport not ready, retrying in 200ms...');
setTimeout(() => this.initWebChannel(), 200);
return;
}
new QWebChannel(qt.webChannelTransport, (channel) => {
// Validate channel and critical objects exist
if (!channel || !channel.objects) {
console.warn('Invalid WebChannel received, retrying...');
setTimeout(() => this.initWebChannel(), 300);
return;
}
this.channel = channel;
// Wait for both DOM and WebChannel to be fully ready
this.waitForFullInitialization(() => {
this.webChannelReady = true;
// Register for updates from Python with error handling
if (channel.objects.overlay) {
try {
channel.objects.overlay.dataUpdated.connect((data) => {
this.updateOverlay(data);
});
channel.objects.overlay.positionChanged.connect((position, duration) => {
this.updateProgress(position, duration);
});
channel.objects.overlay.videoInfoChanged.connect((info) => {
this.updateVideoInfo(info);
});
console.log('WebChannel connected and ready');
// Process any pending updates after full initialization
setTimeout(() => this.processPendingUpdates(), 100);
} catch (connectError) {
console.error('Error connecting WebChannel signals:', connectError);
}
} else {
console.warn('WebChannel overlay object not found');
}
});
});
} catch (error) {
console.error('WebChannel initialization error:', error);
// Retry with exponential backoff
setTimeout(() => this.initWebChannel(), 1000);
}
}
waitForFullInitialization(callback) {
const checkReady = () => {
if (document.readyState === 'complete' && this.validateCriticalElements()) {
callback();
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
}
processPendingUpdates() {
// Prevent infinite loops by limiting processing attempts
let processed = 0;
const maxProcessing = 10;
while (this.pendingUpdates.length > 0 && processed < maxProcessing) {
// Double-check readiness before processing
if (!this.isSystemReady()) {
console.log('System not ready during pending updates processing');
break;
}
const update = this.pendingUpdates.shift();
this.updateOverlay(update);
processed++;
}
// If there are still pending updates, schedule another processing cycle
if (this.pendingUpdates.length > 0) {
setTimeout(() => this.processPendingUpdates(), 300);
}
}
updateOverlay(data) {
console.log('Updating overlay with data:', data);
// Enhanced readiness check with multiple validation layers
if (!this.isSystemReady()) {
console.log('System not ready, queuing update');
this.pendingUpdates.push(data);
// Retry with progressive backoff
setTimeout(() => this.processPendingUpdates(), 150);
return;
}
// Validate all critical elements exist before any updates
if (!this.validateCriticalElements()) {
console.warn('Critical elements not available, requeueing update');
this.pendingUpdates.push(data);
setTimeout(() => this.processPendingUpdates(), 200);
return;
}
this.overlayData = { ...this.overlayData, ...data };
// Update title elements with safe element access
if (data.title !== undefined) {
if (!this.safeUpdateElement('titleMain', data.title, 'textContent')) {
console.warn('Failed to update titleMain, queuing for retry');
this.pendingUpdates.push({title: data.title});
return;
}
}
if (data.subtitle !== undefined) {
if (!this.safeUpdateElement('titleSubtitle', data.subtitle, 'textContent')) {
console.warn('Failed to update titleSubtitle, queuing for retry');
this.pendingUpdates.push({subtitle: data.subtitle});
return;
}
}
// Update ticker text
if (data.ticker !== undefined) {
if (!this.safeUpdateElement('tickerText', data.ticker, 'textContent')) {
console.warn('Failed to update tickerText, queuing for retry');
this.pendingUpdates.push({ticker: data.ticker});
return;
}
}
// Show/hide stats panel
if (data.showStats !== undefined) {
if (!this.safeUpdateElement('statsPanel', data.showStats ? 'block' : 'none', 'display')) {
console.warn('Failed to update statsPanel, queuing for retry');
this.pendingUpdates.push({showStats: data.showStats});
return;
}
}
// Update custom CSS if provided
if (data.customCSS) {
this.applyCustomCSS(data.customCSS);
}
}
isSystemReady() {
try {
return this.webChannelReady &&
document.readyState === 'complete' &&
document.getElementById('titleMain') !== null &&
document.body !== null;
} catch (error) {
console.warn('Error in isSystemReady check:', error);
return false;
}
}
validateCriticalElements() {
try {
const criticalIds = ['titleMain', 'titleSubtitle', 'tickerText', 'statsPanel', 'progressBar'];
for (const id of criticalIds) {
const element = document.getElementById(id);
if (!element) {
console.warn(`Critical element ${id} not found`);
return false;
}
// Additional check for element validity
if (element.parentNode === null || !document.contains(element)) {
console.warn(`Critical element ${id} not properly attached to DOM`);
return false;
}
}
return true;
} catch (error) {
console.warn('Error in validateCriticalElements:', error);
return false;
}
}
safeUpdateElement(elementId, value, property = 'textContent') {
try {
const element = document.getElementById(elementId);
if (!element) {
console.warn(`Element ${elementId} not found`);
return false;
}
// Double-check element is still valid and in DOM
if (element.parentNode === null) {
console.warn(`Element ${elementId} no longer in DOM`);
return false;
}
// Additional check for element accessibility
if (!document.contains(element)) {
console.warn(`Element ${elementId} not contained in document`);
return false;
}
if (property === 'display') {
element.style.display = value;
} else if (property === 'width') {
element.style.width = value;
} else if (property === 'textContent') {
// Check if textContent property exists and is writable
if ('textContent' in element) {
element.textContent = value || '';
// Animate only if element update succeeded
this.animateElement(elementId, 'pulse');
} else {
console.warn(`Element ${elementId} does not support textContent`);
return false;
}
}
return true;
} catch (error) {
console.error(`Error updating element ${elementId}:`, error);
return false;
}
}
updateProgress(position, duration) {
try {
// Check system readiness before updating progress
if (!this.isSystemReady()) {
console.log('System not ready for progress update, skipping');
return;
}
const percentage = duration > 0 ? (position / duration) * 100 : 0;
// Safe progress bar update
this.safeUpdateElement('progressBar', `${percentage}%`, 'width');
} catch (error) {
console.error('Error updating progress:', error);
}
}
updateVideoInfo(info) {
console.log('Video info updated:', info);
if (info.resolution) {
const resolutionElement = document.getElementById('resolution');
if (resolutionElement) {
resolutionElement.textContent = info.resolution;
}
}
if (info.bitrate) {
const bitrateElement = document.getElementById('bitrate');
if (bitrateElement) {
bitrateElement.textContent = info.bitrate;
}
}
if (info.codec) {
const codecElement = document.getElementById('codec');
if (codecElement) {
codecElement.textContent = info.codec;
}
}
if (info.fps) {
const fpsElement = document.getElementById('fps');
if (fpsElement) {
fpsElement.textContent = info.fps;
}
}
}
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
animateElement(elementId, animationClass) {
const element = document.getElementById(elementId);
if (element) {
element.style.animation = 'none';
element.offsetHeight; // Trigger reflow
element.style.animation = `${animationClass} 1s ease-in-out`;
}
}
applyCustomCSS(css) {
let styleElement = document.getElementById('customStyles');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'customStyles';
document.head.appendChild(styleElement);
}
styleElement.textContent = css;
}
startCanvasAnimations() {
if (!this.canvas || !this.ctx) {
console.warn('Canvas not ready for animations');
return;
}
const animate = () => {
if (this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Draw animated particles or custom graphics
this.drawParticles();
this.animationFrame = requestAnimationFrame(animate);
}
};
animate();
}
drawParticles() {
// Example particle system - can be customized
const time = Date.now() * 0.001;
for (let i = 0; i < 5; i++) {
const x = (Math.sin(time + i) * 100) + this.canvas.width / 2;
const y = (Math.cos(time + i * 0.5) * 50) + this.canvas.height / 2;
this.ctx.beginPath();
this.ctx.arc(x, y, 2, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(255, 255, 255, ${0.1 + Math.sin(time + i) * 0.1})`;
this.ctx.fill();
}
}
// Public API for Python to call
setTitle(title) {
this.updateOverlay({ title });
}
setSubtitle(subtitle) {
this.updateOverlay({ subtitle });
}
setTicker(ticker) {
this.updateOverlay({ ticker });
}
showStats(show) {
this.updateOverlay({ showStats: show });
}
cleanup() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
}
}
// Initialize overlay manager immediately and safely
let overlayManager = null;
// Function to ensure DOM is ready before any operations
function ensureOverlayReady(callback) {
if (overlayManager && overlayManager.webChannelReady) {
callback();
} else {
setTimeout(() => ensureOverlayReady(callback), 50);
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
overlayManager = new OverlayManager();
window.overlayManager = overlayManager;
});
} else {
// DOM already loaded
overlayManager = new OverlayManager();
window.overlayManager = overlayManager;
}
// Cleanup on unload
window.addEventListener('beforeunload', () => {
if (overlayManager) {
overlayManager.cleanup();
}
});
// Safe global functions that wait for overlay to be ready
window.safeUpdateOverlay = function(data) {
ensureOverlayReady(() => {
if (overlayManager) {
overlayManager.updateOverlay(data);
}
});
};
window.safeUpdateProgress = function(position, duration) {
ensureOverlayReady(() => {
if (overlayManager) {
overlayManager.updateProgress(position, duration);
}
});
};
</script>
<script src="overlay://overlay.js"></script>
</body>
</html>
\ No newline at end of file
......@@ -5,6 +5,7 @@ import os
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
from flask import request, jsonify, g
......@@ -194,46 +195,101 @@ class DashboardAPI:
return {"error": str(e)}
def update_overlay(self, template: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Update video overlay"""
"""Update video overlay and load specific template"""
try:
# Send template change message to load the specific template
template_data = data.copy()
template_data['reload_template'] = True # Flag to trigger template reload
template_data['load_specific_template'] = template # Specific template to load
message = MessageBuilder.template_change(
sender="web_dashboard",
template_name=template,
template_data=data
template_data=template_data
)
message.recipient = "qt_player"
self.message_bus.publish(message)
logger.info(f"Overlay update sent: {template}")
return {"success": True, "template": template}
# Also send a separate overlay update message to ensure data is updated
overlay_message = MessageBuilder.overlay_update(
sender="web_dashboard",
overlay_data=data
)
overlay_message.recipient = "qt_player"
self.message_bus.publish(overlay_message)
logger.info(f"Overlay update and template load sent: {template}")
return {"success": True, "template": template, "loaded": True}
except Exception as e:
logger.error(f"Overlay update error: {e}")
return {"error": str(e)}
def get_templates(self) -> Dict[str, Any]:
"""Get available overlay templates"""
"""Get available overlay templates from both built-in and uploaded directories"""
try:
# This would normally query the template system
templates = {
"news_template": {
"name": "News Template",
"description": "Breaking news with scrolling text",
"fields": ["headline", "ticker_text", "logo_url"]
},
"sports_template": {
"name": "Sports Template",
"description": "Sports scores and updates",
"fields": ["team1", "team2", "score1", "score2", "event"]
},
"simple_template": {
"name": "Simple Template",
"description": "Basic text overlay",
"fields": ["title", "subtitle", "text"]
}
}
return {"templates": templates}
from pathlib import Path
# Get built-in templates directory
builtin_templates_dir = Path(__file__).parent.parent / "qt_player" / "templates"
# Get persistent uploaded templates directory
uploaded_templates_dir = self._get_persistent_templates_dir()
template_list = []
templates_seen = set()
# First scan uploaded templates (these take priority)
if uploaded_templates_dir.exists():
uploaded_files = list(uploaded_templates_dir.glob("*.html"))
for html_file in uploaded_files:
template_name = html_file.stem
template_list.append({
"name": template_name,
"filename": html_file.name,
"display_name": template_name.replace("_", " ").title(),
"source": "uploaded",
"can_delete": True
})
templates_seen.add(template_name)
logger.info(f"Found {len(uploaded_files)} uploaded templates in {uploaded_templates_dir}")
# Then scan built-in templates (only add if not already seen from uploads)
if builtin_templates_dir.exists():
builtin_files = list(builtin_templates_dir.glob("*.html"))
for html_file in builtin_files:
template_name = html_file.stem
if template_name not in templates_seen:
template_list.append({
"name": template_name,
"filename": html_file.name,
"display_name": template_name.replace("_", " ").title(),
"source": "builtin",
"can_delete": False
})
templates_seen.add(template_name)
logger.info(f"Found {len(builtin_files)} built-in templates in {builtin_templates_dir}")
else:
logger.warning(f"Built-in templates directory not found: {builtin_templates_dir}")
# Ensure default template is always available
if not any(t["name"] == "default" for t in template_list):
template_list.insert(0, {
"name": "default",
"filename": "default.html",
"display_name": "Default",
"source": "builtin",
"can_delete": False
})
# Sort templates: uploaded first, then built-in, with default always first
template_list.sort(key=lambda t: (
0 if t["name"] == "default" else 1, # Default first
1 if t["source"] == "builtin" else 0, # Uploaded before built-in
t["name"] # Alphabetical within each group
))
return {"templates": template_list}
except Exception as e:
logger.error(f"Failed to get templates: {e}")
......@@ -581,6 +637,105 @@ class DashboardAPI:
except Exception as e:
logger.error(f"Video deletion error: {e}")
return {"error": str(e)}
def _get_persistent_templates_dir(self) -> Path:
"""Get persistent templates directory for user uploads"""
try:
import platform
from pathlib import Path
system = platform.system()
if system == "Windows":
# Use AppData/Roaming on Windows
app_data = os.getenv('APPDATA', os.path.expanduser('~'))
templates_dir = Path(app_data) / "MbetterClient" / "templates"
elif system == "Darwin": # macOS
# Use ~/Library/Application Support on macOS
templates_dir = Path.home() / "Library" / "Application Support" / "MbetterClient" / "templates"
else: # Linux and other Unix-like systems
# Use ~/.config on Linux
config_home = os.getenv('XDG_CONFIG_HOME', str(Path.home() / ".config"))
templates_dir = Path(config_home) / "MbetterClient" / "templates"
logger.debug(f"Persistent templates directory: {templates_dir}")
return templates_dir
except Exception as e:
logger.error(f"Failed to determine persistent templates directory: {e}")
# Fallback to local directory
return Path.cwd() / "user_templates"
def upload_template(self, file_data, template_name: str = None) -> Dict[str, Any]:
"""Upload template file to persistent directory"""
try:
from werkzeug.utils import secure_filename
# Get persistent templates directory
templates_dir = self._get_persistent_templates_dir()
templates_dir.mkdir(parents=True, exist_ok=True)
# Secure the filename
filename = secure_filename(file_data.filename)
if not filename:
return {"error": "Invalid filename"}
# Ensure .html extension
if not filename.lower().endswith('.html'):
filename += '.html'
# Use provided template name or derive from filename
if template_name:
filename = secure_filename(template_name)
if not filename.lower().endswith('.html'):
filename += '.html'
# Save to persistent templates directory
file_path = templates_dir / filename
file_data.save(str(file_path))
logger.info(f"Template uploaded successfully: {file_path}")
return {
"success": True,
"message": "Template uploaded successfully",
"filename": filename,
"template_name": Path(filename).stem
}
except Exception as e:
logger.error(f"Template upload error: {e}")
return {"error": str(e)}
def delete_template(self, template_name: str) -> Dict[str, Any]:
"""Delete uploaded template file"""
try:
# Only allow deletion of uploaded templates, not built-in ones
templates_dir = self._get_persistent_templates_dir()
# Add .html extension if not present
if not template_name.endswith('.html'):
template_name += '.html'
template_path = templates_dir / template_name
if not template_path.exists():
return {"error": "Template not found in uploaded templates"}
# Delete the file
template_path.unlink()
logger.info(f"Template deleted: {template_path}")
return {
"success": True,
"message": "Template deleted successfully",
"template_name": Path(template_name).stem
}
except Exception as e:
logger.error(f"Template deletion error: {e}")
return {"error": str(e)}
# Route functions for Flask
......
......@@ -7,6 +7,7 @@ from datetime import datetime
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, session
from flask_login import login_required, current_user, login_user, logout_user
from werkzeug.security import check_password_hash
from werkzeug.utils import secure_filename
from .auth import AuthenticatedUser
from ..core.message_bus import Message, MessageType
......@@ -665,4 +666,77 @@ def shutdown_application():
except Exception as e:
logger.error(f"API shutdown error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/templates/upload', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def upload_template():
"""Upload template file"""
try:
if 'template' not in request.files:
return jsonify({"error": "No template file provided"}), 400
file = request.files['template']
if file.filename == '':
return jsonify({"error": "No file selected"}), 400
# Get template name from form or use filename
template_name = request.form.get('template_name', '')
result = api_bp.api.upload_template(file, template_name)
return jsonify(result)
except Exception as e:
logger.error(f"Template upload error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/templates/<template_name>', methods=['GET'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_template_content(template_name):
"""Get template content for preview"""
try:
from pathlib import Path
# Add .html extension if not present
if not template_name.endswith('.html'):
template_name += '.html'
# Get persistent uploaded templates directory
uploaded_templates_dir = api_bp.api._get_persistent_templates_dir()
# Get built-in templates directory
builtin_templates_dir = Path(__file__).parent.parent / "qt_player" / "templates"
# First try uploaded templates (user uploads take priority)
template_path = uploaded_templates_dir / template_name
# If not found in uploaded, try built-in templates
if not template_path.exists():
template_path = builtin_templates_dir / template_name
if template_path.exists():
with open(template_path, 'r', encoding='utf-8') as f:
content = f.read()
return content, 200, {'Content-Type': 'text/html'}
else:
return jsonify({"error": "Template not found"}), 404
except Exception as e:
logger.error(f"Template content fetch error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/templates/<template_name>', methods=['DELETE'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
@api_bp.auth_manager.require_admin if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def delete_template(template_name):
"""Delete uploaded template"""
try:
result = api_bp.api.delete_template(template_name)
return jsonify(result)
except Exception as e:
logger.error(f"Template deletion error: {e}")
return jsonify({"error": str(e)}), 500
\ No newline at end of file
class OverlayManager {
constructor() {
this.overlayData = {};
this.pendingUpdates = [];
this.webChannelReady = false;
this.initWebChannel();
this.initCanvas();
}
initWebChannel() {
try {
// Wait for DOM to be fully loaded
this.waitForFullInitialization(() => {
// Check if Qt WebChannel is available
if (typeof Qt === 'object' && typeof QtWebChannel === 'function') {
// Create channel and connect signals
const channel = new QtWebChannel();
// Connect to overlay object
channel.connectTo('overlay', (overlay) => {
// Connect positionChanged signal
overlay.positionChanged.connect((position, duration) => {
if (position !== null && duration !== null) {
this.updateProgress(position, duration);
} else {
console.warn('positionChanged signal received null/undefined parameters, skipping');
}
});
// Connect videoInfoChanged signal
overlay.videoInfoChanged.connect((info) => {
if (info && typeof info === 'object') {
this.updateVideoInfo(info);
} else {
console.warn('videoInfoChanged signal received null/undefined parameter, skipping');
}
});
// Process pending updates after full initialization
setTimeout(() => this.processPendingUpdates(), 100);
});
console.log('WebChannel connected and ready');
} else {
console.warn('QtWebChannel not available');
// Retry with exponential backoff
setTimeout(() => this.initWebChannel(), 1000);
}
});
} catch (error) {
console.error('WebChannel initialization error:', error);
// Retry with exponential backoff
setTimeout(() => this.initWebChannel(), 1000);
}
}
waitForFullInitialization(callback) {
const checkReady = () => {
if (document.readyState === 'complete' && this.validateCriticalElements()) {
callback();
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
}
processPendingUpdates() {
// Prevent infinite loops by limiting processing attempts
let processed = 0;
const maxProcessing = 10;
while (this.pendingUpdates.length > 0 && processed < maxProcessing) {
// Double-check readiness before processing pending update
if (!this.isSystemReady()) {
console.log('System not ready during pending updates processing');
break;
}
const update = this.pendingUpdates.shift();
// Add null check before processing pending update
if (update && typeof update === 'object') {
this.updateOverlay(update);
processed++;
} else {
console.warn('Skipping null/invalid pending update:', update);
processed++;
}
}
// If there are still pending updates, schedule another processing cycle
if (this.pendingUpdates.length > 0) {
setTimeout(() => this.processPendingUpdates(), 300);
}
}
updateOverlay(data) {
// Comprehensive null/undefined check for data parameter
if (!data) {
console.warn('updateOverlay called with null/undefined data');
console.warn('Call stack:', new Error().stack);
return;
}
console.log('Updating overlay with data:', data);
// Enhanced readiness check with multiple validation layers
if (!this.isSystemReady()) {
console.log('System not ready, queuing update');
this.pendingUpdates.push(data);
// Retry with progressive backoff
setTimeout(() => this.processPendingUpdates(), 150);
return;
}
// Validate all critical elements exist before any updates
if (!this.validateCriticalElements()) {
console.warn('Critical elements not available, requeueing update');
this.pendingUpdates.push(data);
setTimeout(() => this.processPendingUpdates(), 200);
return;
}
this.overlayData = { ...this.overlayData, ...data };
// Update title elements with safe element access
// Additional null check for data.title
if (data.hasOwnProperty('title') && data.title !== undefined && data.title !== null) {
if (!this.safeUpdateElement('titleMain', data.title, 'textContent')) {
console.warn('Failed to update titleMain, queuing for retry');
this.pendingUpdates.push({title: data.title});
return;
}
}
// Additional null check for data.subtitle
if (data.hasOwnProperty('subtitle') && data.subtitle !== undefined && data.subtitle !== null) {
if (!this.safeUpdateElement('titleSubtitle', data.subtitle, 'textContent')) {
console.warn('Failed to update titleSubtitle, queuing for retry');
this.pendingUpdates.push({subtitle: data.subtitle});
return;
}
}
// Update ticker text with null check
if (data.hasOwnProperty('ticker') && data.ticker !== undefined && data.ticker !== null) {
if (!this.safeUpdateElement('tickerText', data.ticker, 'textContent')) {
console.warn('Failed to update tickerText, queuing for retry');
this.pendingUpdates.push({ticker: data.ticker});
return;
}
}
// Show/hide stats panel with null check
if (data.hasOwnProperty('showStats') && data.showStats !== undefined && data.showStats !== null) {
if (!this.safeUpdateElement('statsPanel', data.showStats ? 'block' : 'none', 'display')) {
console.warn('Failed to update statsPanel, queuing for retry');
this.pendingUpdates.push({showStats: data.showStats});
return;
}
}
// Update custom CSS if provided with null check
if (data.hasOwnProperty('customCSS') && data.customCSS !== undefined && data.customCSS !== null) {
this.applyCustomCSS(data.customCSS);
}
}
isSystemReady() {
try {
return this.webChannelReady &&
document.readyState === 'complete' &&
document.getElementById('titleMain') !== null &&
document.body !== null;
} catch (error) {
console.warn('Error in isSystemReady check:', error);
return false;
}
}
validateCriticalElements() {
try {
const criticalIds = ['titleMain', 'titleSubtitle', 'tickerText', 'statsPanel', 'progressBar'];
for (const id of criticalIds) {
const element = document.getElementById(id);
if (!element) {
console.warn(`Critical element ${id} not found`);
return false;
}
// Additional check for element validity
if (element.parentNode === null || !document.contains(element)) {
console.warn(`Critical element ${id} not properly attached to DOM`);
return false;
}
}
return true;
} catch (error) {
console.warn('Error in validateCriticalElements:', error);
return false;
}
}
safeUpdateElement(elementId, value, property = 'textContent') {
// Null check for elementId parameter
if (elementId === null || elementId === undefined) {
console.warn('safeUpdateElement called with null/undefined elementId');
return false;
}
// For textContent property, convert null/undefined to empty string
if (property === 'textContent' && (value === null || value === undefined)) {
value = '';
}
// For other properties, null/undefined values are not allowed
if (property !== 'textContent' && (value === null || value === undefined)) {
console.warn(`safeUpdateElement called with null/undefined value for property ${property}`);
return false;
}
try {
const element = document.getElementById(elementId);
if (!element) {
console.warn(`Element ${elementId} not found`);
return false;
}
// Double-check element is still valid and in DOM
if (element.parentNode === null) {
console.warn(`Element ${elementId} no longer in DOM`);
return false;
}
// Additional check for element accessibility
if (!document.contains(element)) {
console.warn(`Element ${elementId} not contained in document`);
return false;
}
if (property === 'display') {
element.style.display = value;
} else if (property === 'width') {
element.style.width = value;
} else if (property === 'textContent') {
// Check if textContent property exists and is writable
if ('textContent' in element) {
element.textContent = value;
// Animate only if element update succeeded
this.animateElement(elementId, 'pulse');
} else {
console.warn(`Element ${elementId} does not support textContent`);
return false;
}
}
return true;
} catch (error) {
console.error(`Error updating element ${elementId}:`, error);
return false;
}
}
updateProgress(position, duration) {
// Null checks for position and duration parameters
if (position === null || position === undefined) {
console.warn('updateProgress called with null/undefined position');
return;
}
if (duration === null || duration === undefined) {
console.warn('updateProgress called with null/undefined duration');
return;
}
try {
// Check system readiness before updating progress
if (!this.isSystemReady()) {
console.log('System not ready for progress update, skipping');
return;
}
const percentage = duration > 0 ? (position / duration) * 100 : 0;
// Safe progress bar update
this.safeUpdateElement('progressBar', `${percentage}%`, 'width');
} catch (error) {
console.error('Error updating progress:', error);
}
}
updateVideoInfo(info) {
// Comprehensive null/undefined check for info parameter
if (!info) {
console.warn('updateVideoInfo called with null/undefined info');
return;
}
console.log('Video info updated:', info);
if (info.hasOwnProperty('resolution') && info.resolution !== undefined && info.resolution !== null) {
const resolutionElement = document.getElementById('resolution');
if (resolutionElement) {
resolutionElement.textContent = info.resolution;
}
}
if (info.hasOwnProperty('bitrate') && info.bitrate !== undefined && info.bitrate !== null) {
const bitrateElement = document.getElementById('bitrate');
if (bitrateElement) {
bitrateElement.textContent = info.bitrate;
}
}
if (info.hasOwnProperty('codec') && info.codec !== undefined && info.codec !== null) {
const codecElement = document.getElementById('codec');
if (codecElement) {
codecElement.textContent = info.codec;
}
}
if (info.hasOwnProperty('fps') && info.fps !== undefined && info.fps !== null) {
const fpsElement = document.getElementById('fps');
if (fpsElement) {
fpsElement.textContent = info.fps;
}
}
}
formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
animateElement(elementId, animationClass) {
// Null checks for elementId and animationClass parameters
if (elementId === null || elementId === undefined) {
console.warn('animateElement called with null/undefined elementId');
return;
}
if (animationClass === null || animationClass === undefined) {
console.warn('animateElement called with null/undefined animationClass');
return;
}
const element = document.getElementById(elementId);
if (element) {
element.style.animation = 'none';
element.offsetHeight; // Trigger reflow
element.style.animation = `${animationClass} 1s ease-in-out`;
}
}
applyCustomCSS(css) {
let styleElement = document.getElementById('customStyles');
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = 'customStyles';
document.head.appendChild(styleElement);
}
styleElement.textContent = css;
}
initCanvas() {
this.canvas = document.getElementById('canvasOverlay');
this.ctx = this.canvas ? this.canvas.getContext('2d') : null;
this.animationFrame = null;
// Initialize canvas dimensions
this.resizeCanvas();
// Setup resize listener
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
if (!this.canvas) return;
// Get window dimensions
const width = window.innerWidth;
const height = window.innerHeight;
// Update canvas dimensions
this.canvas.width = width;
this.canvas.height = height;
// Redraw if animations are running
if (this.animationFrame) {
this.startCanvasAnimations();
}
}
startCanvasAnimations() {
if (!this.canvas || !this.ctx) {
console.warn('Canvas not ready for animations');
return;
}
const animate = () => {
if (this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Draw animated particles or custom graphics
this.drawParticles();
this.animationFrame = requestAnimationFrame(animate);
}
};
animate();
}
drawParticles() {
// Example particle system - can be customized
const time = Date.now() * 0.001;
for (let i = 0; i < 5; i++) {
const x = (Math.sin(time + i) * 100) + this.canvas.width / 2;
const y = (Math.cos(time + i * 0.5) * 50) + this.canvas.height / 2;
this.ctx.beginPath();
this.ctx.arc(x, y, 2, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(255, 255, 255, ${0.1 + Math.sin(time + i) * 0.1})`;
this.ctx.fill();
}
}
// Public API for Python to call
setTitle(title) {
// Null check for title parameter
if (title === null || title === undefined) {
console.warn('setTitle called with null/undefined title');
return;
}
this.updateOverlay({ title });
}
setSubtitle(subtitle) {
// Null check for subtitle parameter
if (subtitle === null || subtitle === undefined) {
console.warn('setSubtitle called with null/undefined subtitle');
return;
}
this.updateOverlay({ subtitle });
}
setTicker(ticker) {
// Null check for ticker parameter
if (ticker === null || ticker === undefined) {
console.warn('setTicker called with null/undefined ticker');
return;
}
this.updateOverlay({ ticker });
}
showStats(show) {
// Null check for show parameter
if (show === null || show === undefined) {
console.warn('showStats called with null/undefined show');
return;
}
this.updateOverlay({ showStats: show });
}
cleanup() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
}
}
// Initialize overlay manager immediately and safely
let overlayManager = null;
// Function to ensure DOM is ready before any operations
function ensureOverlayReady(callback) {
if (overlayManager && overlayManager.webChannelReady) {
callback();
} else {
setTimeout(() => ensureOverlayReady(callback), 50);
}
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
overlayManager = new OverlayManager();
window.overlayManager = overlayManager;
});
} else {
// DOM already loaded
overlayManager = new OverlayManager();
window.overlayManager = overlayManager;
}
// Cleanup on unload
window.addEventListener('beforeunload', () => {
if (overlayManager) {
overlayManager.cleanup();
}
});
// Safe global functions that wait for overlay to be ready
window.safeUpdateOverlay = function(data) {
// Comprehensive null/undefined check for data parameter
if (!data) {
console.warn('safeUpdateOverlay called with null/undefined data');
return;
}
ensureOverlayReady(() => {
if (overlayManager) {
overlayManager.updateOverlay(data);
}
});
};
window.safeUpdateProgress = function(position, duration) {
// Null checks for position and duration parameters
if (position === null || position === undefined) {
console.warn('safeUpdateProgress called with null/undefined position');
return;
}
if (duration === null || duration === undefined) {
console.warn('safeUpdateProgress called with null/undefined duration');
return;
}
ensureOverlayReady(() => {
if (overlayManager) {
overlayManager.updateProgress(position, duration);
}
});
};
\ No newline at end of file
......@@ -245,9 +245,7 @@
<div class="mb-3">
<label class="form-label">Template</label>
<select class="form-select" id="video-template">
<option value="news_template">News Template</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
<option value="">Loading templates...</option>
</select>
</div>
</form>
......@@ -274,9 +272,7 @@
<div class="mb-3">
<label class="form-label">Template</label>
<select class="form-select" id="overlay-template">
<option value="news_template">News Template</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
<option value="">Loading templates...</option>
</select>
</div>
<div class="mb-3">
......@@ -305,6 +301,9 @@
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load available templates on page load
loadAvailableTemplates();
// Quick action buttons
document.getElementById('btn-play-video').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show();
......@@ -465,5 +464,65 @@ document.addEventListener('DOMContentLoaded', function() {
setInterval(updateSystemStatus, 30000); // 30 seconds
setInterval(updateVideoStatus, 10000); // 10 seconds
});
function loadAvailableTemplates() {
fetch('/api/templates')
.then(response => response.json())
.then(data => {
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template');
// Clear loading options
videoTemplateSelect.innerHTML = '';
overlayTemplateSelect.innerHTML = '';
if (data.templates && Array.isArray(data.templates)) {
data.templates.forEach(template => {
// Add to video template select
const videoOption = document.createElement('option');
videoOption.value = template.name;
videoOption.textContent = template.display_name || template.name;
videoTemplateSelect.appendChild(videoOption);
// Add to overlay template select
const overlayOption = document.createElement('option');
overlayOption.value = template.name;
overlayOption.textContent = template.display_name || template.name;
overlayTemplateSelect.appendChild(overlayOption);
});
// Select default template if available
const defaultVideoOption = videoTemplateSelect.querySelector('option[value="default"]');
if (defaultVideoOption) {
defaultVideoOption.selected = true;
}
const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]');
if (defaultOverlayOption) {
defaultOverlayOption.selected = true;
}
} else {
// Fallback if no templates found
const videoOption = document.createElement('option');
videoOption.value = 'default';
videoOption.textContent = 'Default';
videoTemplateSelect.appendChild(videoOption);
const overlayOption = document.createElement('option');
overlayOption.value = 'default';
overlayOption.textContent = 'Default';
overlayTemplateSelect.appendChild(overlayOption);
}
})
.catch(error => {
console.error('Error loading templates:', error);
// Fallback template options
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template');
videoTemplateSelect.innerHTML = '<option value="default">Default</option>';
overlayTemplateSelect.innerHTML = '<option value="default">Default</option>';
});
}
</script>
{% endblock %}
\ No newline at end of file
......@@ -9,13 +9,32 @@
<h1>Template Management</h1>
<p>Manage video overlay templates and create custom designs.</p>
<!-- Template Upload -->
<div class="card mb-4">
<div class="card-header">
<h5>Upload Template</h5>
</div>
<div class="card-body">
<form id="upload-template-form" enctype="multipart/form-data">
<div class="row">
<div class="col-md-8">
<input type="file" class="form-control" id="template-file" name="template" accept=".html" required>
<div class="form-text">Select an HTML template file to upload. Uploaded templates with the same name will override built-in templates.</div>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-upload"></i> Upload Template
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Template List -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card-header">
<h5>Available Templates</h5>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createTemplateModal">
<i class="fas fa-plus"></i> Create Template
</button>
</div>
<div class="card-body">
<div class="table-responsive">
......@@ -23,8 +42,7 @@
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Author</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
......@@ -51,44 +69,55 @@
</div>
</div>
<!-- Create Template Modal -->
<div class="modal fade" id="createTemplateModal" tabindex="-1" aria-labelledby="createTemplateModalLabel" aria-hidden="true">
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteTemplateModal" tabindex="-1" aria-labelledby="deleteTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createTemplateModalLabel">Create New Template</h5>
<h5 class="modal-title" id="deleteTemplateModalLabel">Delete Template</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="create-template-form">
<div class="mb-3">
<label for="template-name" class="form-label">Template Name</label>
<input type="text" class="form-control" id="template-name" required>
</div>
<div class="mb-3">
<label for="template-category" class="form-label">Category</label>
<select class="form-select" id="template-category">
<option value="news">News</option>
<option value="sports">Sports</option>
<option value="entertainment">Entertainment</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="mb-3">
<label for="template-description" class="form-label">Description</label>
<textarea class="form-control" id="template-description" rows="3"></textarea>
</div>
</form>
<p>Are you sure you want to delete the template "<span id="delete-template-name"></span>"?</p>
<p class="text-muted">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="create-template-btn">Create Template</button>
<button type="button" class="btn btn-danger" id="confirm-delete-btn">Delete</button>
</div>
</div>
</div>
</div>
<!-- View Template Code Modal -->
<div class="modal fade" id="viewTemplateModal" tabindex="-1" aria-labelledby="viewTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="viewTemplateModalLabel">Template Code</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Template: <span id="view-template-name"></span></h6>
<button type="button" class="btn btn-sm btn-outline-primary" id="copy-template-code">
<i class="fas fa-copy me-1"></i>Copy Code
</button>
</div>
<div class="border rounded p-3" style="background-color: #f8f9fa;">
<pre id="template-code-content" class="mb-0" style="max-height: 500px; overflow-y: auto; font-size: 14px; line-height: 1.4;"><code></code></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
let templateToDelete = null;
// Load templates on page load
function loadTemplates() {
fetch('/api/templates')
......@@ -97,91 +126,265 @@
const tbody = document.getElementById('templates-table-body');
tbody.innerHTML = '';
data.forEach(template => {
data.templates.forEach(template => {
const row = document.createElement('tr');
const sourceIcon = template.source === 'uploaded' ?
'<i class="fas fa-cloud text-primary" title="Uploaded template"></i>' :
'<i class="fas fa-box text-secondary" title="Built-in template"></i>';
const deleteButton = template.can_delete ?
`<button class="btn btn-sm btn-danger delete-template" data-name="${template.name}" title="Delete template">
<i class="fas fa-trash"></i>
</button>` : '';
row.innerHTML = `
<td>${template.name}</td>
<td>${template.category}</td>
<td>${template.author || 'System'}</td>
<td>
<button class="btn btn-sm btn-primary edit-template" data-id="${template.id}">Edit</button>
<button class="btn btn-sm btn-info preview-template" data-id="${template.id}">Preview</button>
<button class="btn btn-sm btn-danger delete-template" data-id="${template.id}">Delete</button>
<strong>${template.name}</strong>
${template.source === 'uploaded' ? '<span class="badge bg-primary ms-2">Override</span>' : ''}
</td>
<td>${sourceIcon} ${template.source === 'uploaded' ? 'Uploaded' : 'Built-in'}</td>
<td>
<button class="btn btn-sm btn-info preview-template" data-name="${template.name}" title="Preview template">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-secondary view-template ms-1" data-name="${template.name}" title="View template code">
<i class="fas fa-code"></i>
</button>
${deleteButton}
</td>
`;
tbody.appendChild(row);
});
// Add event listeners to action buttons
document.querySelectorAll('.edit-template').forEach(btn => {
document.querySelectorAll('.preview-template').forEach(btn => {
btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id');
editTemplate(templateId);
const templateName = this.getAttribute('data-name');
previewTemplate(templateName);
});
});
document.querySelectorAll('.preview-template').forEach(btn => {
document.querySelectorAll('.view-template').forEach(btn => {
btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id');
previewTemplate(templateId);
const templateName = this.getAttribute('data-name');
viewTemplateCode(templateName);
});
});
document.querySelectorAll('.delete-template').forEach(btn => {
btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id');
deleteTemplate(templateId);
const templateName = this.getAttribute('data-name');
showDeleteConfirmation(templateName);
});
});
})
.catch(error => {
console.error('Error loading templates:', error);
showAlert('Error loading templates: ' + error.message, 'danger');
});
}
// Preview template
function previewTemplate(templateId) {
const previewContainer = document.getElementById('template-preview');
previewContainer.innerHTML = `<p>Loading preview for template ${templateId}...</p>`;
// In a real implementation, this would show a visual preview of the template
// Preview template in popup window
function previewTemplate(templateName) {
// Open template in a new popup window
const previewUrl = `/api/templates/${templateName}`;
const popup = window.open(previewUrl, `template-preview-${templateName}`,
'width=800,height=600,scrollbars=yes,resizable=yes,toolbar=no,location=no,status=no');
if (!popup) {
showAlert('Popup blocked. Please allow popups for template preview.', 'warning');
} else {
// Set popup title after it loads
popup.onload = function() {
try {
popup.document.title = `Template Preview: ${templateName}`;
} catch (e) {
// Ignore cross-origin errors
}
};
}
}
// Edit template
function editTemplate(templateId) {
alert(`Edit template ${templateId} - This would open the template editor`);
// Show delete confirmation modal
function showDeleteConfirmation(templateName) {
templateToDelete = templateName;
document.getElementById('delete-template-name').textContent = templateName;
const modal = new bootstrap.Modal(document.getElementById('deleteTemplateModal'));
modal.show();
}
// Delete template
function deleteTemplate(templateId) {
if (confirm('Are you sure you want to delete this template?')) {
// In a real implementation, this would delete the template
alert(`Delete template ${templateId} - This would delete the template from the database`);
}
function deleteTemplate(templateName) {
fetch(`/api/templates/${templateName}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Template "${templateName}" deleted successfully`, 'success');
loadTemplates(); // Reload the template list
} else {
showAlert(`Error deleting template: ${data.error}`, 'danger');
}
})
.catch(error => {
console.error('Error deleting template:', error);
showAlert(`Error deleting template: ${error.message}`, 'danger');
});
}
// Upload template
function uploadTemplate(formData) {
fetch('/api/templates/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(`Template "${data.filename}" uploaded successfully`, 'success');
document.getElementById('upload-template-form').reset();
loadTemplates(); // Reload the template list
} else {
showAlert(`Error uploading template: ${data.error}`, 'danger');
}
})
.catch(error => {
console.error('Error uploading template:', error);
showAlert(`Error uploading template: ${error.message}`, 'danger');
});
}
// Create template
document.getElementById('create-template-btn').addEventListener('click', function() {
const name = document.getElementById('template-name').value;
const category = document.getElementById('template-category').value;
const description = document.getElementById('template-description').value;
// View template code in modal
function viewTemplateCode(templateName) {
document.getElementById('view-template-name').textContent = templateName;
if (!name) {
alert('Template name is required');
return;
// Load template content
fetch(`/api/templates/${templateName}`)
.then(response => response.text())
.then(html => {
const codeElement = document.querySelector('#template-code-content code');
codeElement.textContent = html;
// Store the code for copying
window.currentTemplateCode = html;
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('viewTemplateModal'));
modal.show();
})
.catch(error => {
console.error('Error loading template code:', error);
showAlert(`Error loading template code: ${error.message}`, 'danger');
});
}
// Copy template code to clipboard
function copyTemplateCode() {
if (window.currentTemplateCode) {
navigator.clipboard.writeText(window.currentTemplateCode).then(() => {
showAlert('Template code copied to clipboard!', 'success');
// Briefly change button text to show feedback
const copyBtn = document.getElementById('copy-template-code');
const originalText = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="fas fa-check me-1"></i>Copied!';
copyBtn.classList.remove('btn-outline-primary');
copyBtn.classList.add('btn-success');
setTimeout(() => {
copyBtn.innerHTML = originalText;
copyBtn.classList.remove('btn-success');
copyBtn.classList.add('btn-outline-primary');
}, 2000);
}).catch(err => {
console.error('Failed to copy template code:', err);
showAlert('Failed to copy template code to clipboard', 'danger');
});
}
}
// Utility function to escape HTML for preview
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Utility function to show alerts
function showAlert(message, type) {
const alertContainer = document.getElementById('alert-container') || createAlertContainer();
const alertId = 'alert-' + Date.now();
const alertHtml = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert" id="${alertId}">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
// In a real implementation, this would create the template
alert(`Create template: ${name} (${category}) - This would save the template to the database`);
alertContainer.innerHTML = alertHtml;
// Close modal and refresh template list
const modal = bootstrap.Modal.getInstance(document.getElementById('createTemplateModal'));
modal.hide();
loadTemplates();
});
// Auto-dismiss after 5 seconds
setTimeout(() => {
const alert = document.getElementById(alertId);
if (alert) {
const bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
bsAlert.close();
}
}, 5000);
}
// Load templates when page loads
// Create alert container if it doesn't exist
function createAlertContainer() {
const container = document.createElement('div');
container.id = 'alert-container';
container.className = 'position-fixed top-0 end-0 p-3';
container.style.zIndex = '1050';
document.body.appendChild(container);
return container;
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
loadTemplates();
// Template upload form
document.getElementById('upload-template-form').addEventListener('submit', function(e) {
e.preventDefault();
const fileInput = document.getElementById('template-file');
const file = fileInput.files[0];
if (!file) {
showAlert('Please select a template file', 'warning');
return;
}
if (!file.name.endsWith('.html')) {
showAlert('Please select an HTML file', 'warning');
return;
}
const formData = new FormData();
formData.append('template', file);
uploadTemplate(formData);
});
// Delete confirmation
document.getElementById('confirm-delete-btn').addEventListener('click', function() {
if (templateToDelete) {
deleteTemplate(templateToDelete);
templateToDelete = null;
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteTemplateModal'));
modal.hide();
}
});
// Copy template code button
document.getElementById('copy-template-code').addEventListener('click', copyTemplateCode);
});
</script>
{% endblock %}
\ No newline at end of file
......@@ -38,9 +38,7 @@
<div class="mb-3">
<label for="template-select" class="form-label">Template</label>
<select class="form-select" id="template-select">
<option value="news_template">News Template</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
<option value="">Loading templates...</option>
</select>
</div>
<div class="mb-3">
......@@ -56,6 +54,47 @@
</div>
<script>
// Load available templates on page load
document.addEventListener('DOMContentLoaded', function() {
loadAvailableTemplates();
});
function loadAvailableTemplates() {
fetch('/api/templates')
.then(response => response.json())
.then(data => {
const templateSelect = document.getElementById('template-select');
templateSelect.innerHTML = ''; // Clear loading option
if (data.templates && Array.isArray(data.templates)) {
data.templates.forEach(template => {
const option = document.createElement('option');
option.value = template.name;
option.textContent = template.display_name || template.name;
templateSelect.appendChild(option);
});
// Select default template if available
const defaultOption = templateSelect.querySelector('option[value="default"]');
if (defaultOption) {
defaultOption.selected = true;
}
} else {
// Fallback if no templates found
const option = document.createElement('option');
option.value = 'default';
option.textContent = 'Default';
templateSelect.appendChild(option);
}
})
.catch(error => {
console.error('Error loading templates:', error);
// Fallback template option
const templateSelect = document.getElementById('template-select');
templateSelect.innerHTML = '<option value="default">Default</option>';
});
}
// Video control functions
document.getElementById('play-btn').addEventListener('click', function() {
fetch('/api/video/control', {
......@@ -114,7 +153,7 @@
const template = document.getElementById('template-select').value;
const text = document.getElementById('overlay-text').value;
fetch('/api/overlay/update', {
fetch('/api/overlay', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
......
......@@ -27,9 +27,7 @@
<div class="mb-3">
<label class="form-label">Template</label>
<select class="form-select" id="video-template">
<option value="news_template">News Template</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
<option value="">Loading templates...</option>
</select>
</div>
<button type="submit" class="btn btn-primary" id="upload-btn">
......@@ -114,6 +112,9 @@
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load available templates on page load
loadAvailableTemplates();
// Elements
const uploadForm = document.getElementById('upload-form');
const videoFileInput = document.getElementById('video-file');
......@@ -130,6 +131,43 @@ document.addEventListener('DOMContentLoaded', function() {
// Store uploaded videos
let uploadedVideos = [];
// Load available templates function
function loadAvailableTemplates() {
fetch('/api/templates')
.then(response => response.json())
.then(data => {
const templateSelect = document.getElementById('video-template');
templateSelect.innerHTML = ''; // Clear loading option
if (data.templates && Array.isArray(data.templates)) {
data.templates.forEach(template => {
const option = document.createElement('option');
option.value = template.name;
option.textContent = template.display_name || template.name;
templateSelect.appendChild(option);
});
// Select default template if available
const defaultOption = templateSelect.querySelector('option[value="default"]');
if (defaultOption) {
defaultOption.selected = true;
}
} else {
// Fallback if no templates found
const option = document.createElement('option');
option.value = 'default';
option.textContent = 'Default';
templateSelect.appendChild(option);
}
})
.catch(error => {
console.error('Error loading templates:', error);
// Fallback template option
const templateSelect = document.getElementById('video-template');
templateSelect.innerHTML = '<option value="default">Default</option>';
});
}
// Handle form submission
uploadForm.addEventListener('submit', function(e) {
e.preventDefault();
......
......@@ -27,6 +27,7 @@ python-dotenv>=0.19.0
# Utilities and system
psutil>=5.8.0
click>=8.0.0
watchdog>=3.0.0
# Video and image processing
opencv-python>=4.5.0
......
......@@ -70,8 +70,11 @@ def test_video_playback_native():
'subtitle': 'Video should be VISIBLE underneath this overlay',
'ticker': 'If you can see moving colors/patterns, video is working! Native overlay should not block video.'
}
overlay_view = window.video_widget.get_overlay_view()
overlay_view.update_overlay_data(overlay_data)
# Use the new separate window overlay
if hasattr(window, 'window_overlay') and window.window_overlay:
window.window_overlay.update_overlay_data(overlay_data)
else:
print("Warning: No window overlay available")
# Play video after 2 seconds
QTimer.singleShot(2000, play_test_video)
......@@ -117,16 +120,20 @@ def test_video_playback_webengine():
# Wait for WebEngine to be ready before updating
def update_overlay_when_ready():
overlay_view = window.video_widget.get_overlay_view()
if hasattr(overlay_view, 'overlay_channel') and overlay_view.overlay_channel:
if window._is_webengine_ready(overlay_view):
overlay_view.update_overlay_data(overlay_data)
print("WebEngine overlay updated")
# Use the new separate window overlay
if hasattr(window, 'window_overlay') and window.window_overlay:
overlay_view = window.window_overlay
if hasattr(overlay_view, 'overlay_channel') and overlay_view.overlay_channel:
if window._is_webengine_ready(overlay_view):
overlay_view.update_overlay_data(overlay_data)
print("WebEngine overlay updated")
else:
print("WebEngine not ready, retrying...")
QTimer.singleShot(1000, update_overlay_when_ready)
else:
print("WebEngine not ready, retrying...")
QTimer.singleShot(1000, update_overlay_when_ready)
overlay_view.update_overlay_data(overlay_data)
else:
overlay_view.update_overlay_data(overlay_data)
print("Warning: No window overlay available")
QTimer.singleShot(3000, update_overlay_when_ready)
......@@ -173,8 +180,11 @@ def test_uploaded_video():
'subtitle': 'Testing uploaded video with native overlay',
'ticker': 'This is a real uploaded video file. Video should be visible with native overlay.'
}
overlay_view = window.video_widget.get_overlay_view()
overlay_view.update_overlay_data(overlay_data)
# Use the new separate window overlay
if hasattr(window, 'window_overlay') and window.window_overlay:
window.window_overlay.update_overlay_data(overlay_data)
else:
print("Warning: No window overlay available")
QTimer.singleShot(2000, play_uploaded_video)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment