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 @@ ...@@ -2,6 +2,45 @@
All notable changes to this project will be documented in this file. 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 ## [1.2.1] - 2025-08-20
### Fixed ### Fixed
......
...@@ -5,6 +5,7 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -5,6 +5,7 @@ A cross-platform multimedia client application with video playback, web dashboar
## Features ## Features
- **PyQt Video Player**: Fullscreen video playback with dual overlay system (WebEngine and native Qt widgets) - **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 - **Web Dashboard**: Authentication, user management, configuration interface, and admin system controls
- **REST API Client**: Configurable external API integration with automatic retry - **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 - **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 ...@@ -19,6 +20,17 @@ A cross-platform multimedia client application with video playback, web dashboar
## Recent Improvements ## 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) ### 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 -**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/ ...@@ -144,9 +156,14 @@ mbetterc/
│ ├── core/ # Main loop and message handling │ ├── core/ # Main loop and message handling
│ └── utils/ # Utility functions │ └── utils/ # Utility functions
├── assets/ # Static assets (images, templates) ├── assets/ # Static assets (images, templates)
├── templates/ # Video overlay templates ├── templates/ # Video overlay templates (built-in)
├── tests/ # Unit tests ├── tests/ # Unit tests
└── docs/ # Documentation └── docs/ # Documentation
# User Data Directories (Created automatically)
# Windows: %APPDATA%\MbetterClient\templates\
# macOS: ~/Library/Application Support/MbetterClient/templates/
# Linux: ~/.config/MbetterClient/templates/
``` ```
### Message System ### Message System
...@@ -189,10 +206,18 @@ Threads communicate via Python Queues with structured messages: ...@@ -189,10 +206,18 @@ Threads communicate via Python Queues with structured messages:
- `PUT /api/config/{section}` - Update configuration section - `PUT /api/config/{section}` - Update configuration section
- `GET /api/config/{section}` - Get specific 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 #### Video Control
- `POST /api/video/control` - Control video playback (play, pause, stop, etc.) - `POST /api/video/control` - Control video playback (play, pause, stop, etc.)
- `GET /api/video/status` - Get current video player status - `GET /api/video/status` - Get current video player status
- `POST /api/video/upload` - Upload video file for playback - `POST /api/video/upload` - Upload video file for playback
- `POST /api/overlay` - Update overlay content and switch templates
### Message Types ### Message Types
...@@ -210,6 +235,7 @@ Threads communicate via Python Queues with structured messages: ...@@ -210,6 +235,7 @@ Threads communicate via Python Queues with structured messages:
#### Configuration #### Configuration
- `CONFIG_UPDATE` - Configuration changed - `CONFIG_UPDATE` - Configuration changed
- `TEMPLATE_CHANGE` - Video template changed - `TEMPLATE_CHANGE` - Video template changed
- `OVERLAY_UPDATE` - Overlay content updated
#### System Messages #### System Messages
- `SYSTEM_SHUTDOWN` - Application shutdown request - `SYSTEM_SHUTDOWN` - Application shutdown request
......
...@@ -124,6 +124,33 @@ def collect_data_files() -> List[tuple]: ...@@ -124,6 +124,33 @@ def collect_data_files() -> List[tuple]:
relative_path = file_path.relative_to(project_root) relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent))) 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 return data_files
...@@ -157,6 +184,11 @@ def collect_hidden_imports() -> List[str]: ...@@ -157,6 +184,11 @@ def collect_hidden_imports() -> List[str]:
# Logging # Logging
'loguru', 'loguru',
# File watching for template system
'watchdog',
'watchdog.observers',
'watchdog.events',
# Other dependencies # Other dependencies
'packaging', 'packaging',
'pkg_resources', 'pkg_resources',
......
...@@ -37,6 +37,7 @@ class MbetterClientApplication: ...@@ -37,6 +37,7 @@ class MbetterClientApplication:
self.qt_player = None self.qt_player = None
self.web_dashboard = None self.web_dashboard = None
self.api_client = None self.api_client = None
self.template_watcher = None
# Main loop thread # Main loop thread
self._main_loop_thread: Optional[threading.Thread] = None self._main_loop_thread: Optional[threading.Thread] = None
...@@ -171,6 +172,13 @@ class MbetterClientApplication: ...@@ -171,6 +172,13 @@ class MbetterClientApplication:
try: try:
components_initialized = 0 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 # Initialize PyQt video player
if self.settings.enable_qt: if self.settings.enable_qt:
if self._initialize_qt_player(): if self._initialize_qt_player():
...@@ -206,6 +214,63 @@ class MbetterClientApplication: ...@@ -206,6 +214,63 @@ class MbetterClientApplication:
logger.error(f"Component initialization failed: {e}") logger.error(f"Component initialization failed: {e}")
return False 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: def _initialize_qt_player(self) -> bool:
"""Initialize PyQt video player""" """Initialize PyQt video player"""
try: try:
......
...@@ -233,8 +233,33 @@ class MessageBus: ...@@ -233,8 +233,33 @@ class MessageBus:
def _deliver_to_queue(self, queue: Queue, message: Message) -> bool: def _deliver_to_queue(self, queue: Queue, message: Message) -> bool:
"""Deliver message to a specific queue""" """Deliver message to a specific queue"""
try: 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 # Priority handling - critical messages skip queue size limits
if message.priority >= 2: elif message.priority >= 2:
queue.put(message, block=False) queue.put(message, block=False)
else: else:
# Check queue size for normal messages # Check queue size for normal messages
...@@ -420,6 +445,17 @@ class MessageBuilder: ...@@ -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 @staticmethod
def api_request(sender: str, url: str, method: str = "GET", def api_request(sender: str, url: str, method: str = "GET",
headers: Optional[Dict[str, str]] = None, 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 ...@@ -10,7 +10,7 @@ import json
import threading import threading
import signal import signal
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QStackedWidget QLabel, QPushButton, QSlider, QFrame, QStackedWidget
...@@ -27,11 +27,13 @@ from PyQt6.QtGui import ( ...@@ -27,11 +27,13 @@ from PyQt6.QtGui import (
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget from PyQt6.QtMultimediaWidgets import QVideoWidget
from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineProfile
from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtWebChannel import QWebChannel
from ..core.thread_manager import ThreadedComponent from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import QtConfig from ..config.settings import QtConfig
from .overlay_url_handler import OverlayUrlSchemeHandler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -53,40 +55,119 @@ class OverlayWebChannel(QObject): ...@@ -53,40 +55,119 @@ class OverlayWebChannel(QObject):
@pyqtSlot(str) @pyqtSlot(str)
def updateTitle(self, title: str): def updateTitle(self, title: str):
"""Update main title from JavaScript""" """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): with QMutexLocker(self.mutex):
self.overlay_data['title'] = title self.overlay_data['title'] = title
self.dataUpdated.emit({'title': title}) cleaned_data = self._clean_data({'title': title})
logger.debug(f"Title updated: {title}") if cleaned_data:
self.dataUpdated.emit(cleaned_data)
logger.debug(f"Title updated: {title}")
@pyqtSlot(str) @pyqtSlot(str)
def updateSubtitle(self, subtitle: str): def updateSubtitle(self, subtitle: str):
"""Update subtitle from JavaScript""" """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): with QMutexLocker(self.mutex):
self.overlay_data['subtitle'] = subtitle self.overlay_data['subtitle'] = subtitle
self.dataUpdated.emit({'subtitle': subtitle}) cleaned_data = self._clean_data({'subtitle': subtitle})
logger.debug(f"Subtitle updated: {subtitle}") if cleaned_data:
self.dataUpdated.emit(cleaned_data)
logger.debug(f"Subtitle updated: {subtitle}")
@pyqtSlot(bool) @pyqtSlot(bool)
def toggleStats(self, show: bool): def toggleStats(self, show: bool):
"""Toggle stats panel visibility""" """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): with QMutexLocker(self.mutex):
self.overlay_data['showStats'] = show self.overlay_data['showStats'] = show
self.dataUpdated.emit({'showStats': show}) cleaned_data = self._clean_data({'showStats': show})
logger.debug(f"Stats panel toggled: {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]): def send_data_update(self, data: Dict[str, Any]):
"""Send data update to JavaScript (thread-safe)""" """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): with QMutexLocker(self.mutex):
self.overlay_data.update(data) self.overlay_data.update(cleaned_data)
self.dataUpdated.emit(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): def send_position_update(self, position: float, duration: float):
"""Send playback position update to JavaScript (thread-safe)""" """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) self.positionChanged.emit(position, duration)
def send_video_info(self, info: Dict[str, Any]): def send_video_info(self, info: Dict[str, Any]):
"""Send video information to JavaScript (thread-safe)""" """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): class VideoProcessingWorker(QRunnable):
...@@ -149,8 +230,21 @@ class OverlayWebView(QWebEngineView): ...@@ -149,8 +230,21 @@ class OverlayWebView(QWebEngineView):
super().__init__(parent) super().__init__(parent)
self.web_channel = None self.web_channel = None
self.overlay_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() 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): def setup_web_view(self):
"""Setup web view with proper transparency for overlay""" """Setup web view with proper transparency for overlay"""
...@@ -176,34 +270,138 @@ class OverlayWebView(QWebEngineView): ...@@ -176,34 +270,138 @@ class OverlayWebView(QWebEngineView):
self.web_channel.registerObject("overlay", self.overlay_channel) self.web_channel.registerObject("overlay", self.overlay_channel)
page.setWebChannel(self.web_channel) page.setWebChannel(self.web_channel)
# Load overlay HTML (handle both development and PyInstaller bundle modes) # Load default template
overlay_path = self._get_overlay_path() self.load_template(self.current_template)
if overlay_path and overlay_path.exists():
self.load(QUrl.fromLocalFile(str(overlay_path))) def _setup_custom_scheme(self):
logger.info(f"Loaded overlay HTML: {overlay_path}") """Setup custom URL scheme handler for overlay resources"""
else: try:
logger.error(f"Overlay HTML not found: {overlay_path}") # Get the page's profile
# Load fallback minimal overlay profile = self.page().profile()
self._load_fallback_overlay()
# 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]: def load_template(self, template_name: str):
"""Get overlay HTML path, handling PyInstaller bundle mode""" """Load a specific template file, prioritizing uploaded templates"""
try: try:
# Check if running as PyInstaller bundle # If no template name provided, use default
if hasattr(sys, '_MEIPASS'): if not template_name:
# Running as bundled executable template_name = "default.html"
bundle_path = Path(sys._MEIPASS)
overlay_path = bundle_path / "mbetterclient" / "qt_player" / "overlay.html" # Ensure .html extension
logger.debug(f"Bundle mode - checking overlay at: {overlay_path}") if not template_name.endswith('.html'):
return overlay_path 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: else:
# Running in development mode logger.error(f"No template found: {template_name}")
overlay_path = Path(__file__).parent / "overlay.html" # Load fallback minimal overlay
logger.debug(f"Development mode - checking overlay at: {overlay_path}") self._load_fallback_overlay()
return overlay_path
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: 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 return None
def _load_fallback_overlay(self): def _load_fallback_overlay(self):
...@@ -253,6 +451,11 @@ class OverlayWebView(QWebEngineView): ...@@ -253,6 +451,11 @@ class OverlayWebView(QWebEngineView):
def update_overlay_data(self, data: Dict[str, Any]): def update_overlay_data(self, data: Dict[str, Any]):
"""Update overlay with new data""" """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: if self.overlay_channel:
self.overlay_channel.send_data_update(data) self.overlay_channel.send_data_update(data)
...@@ -850,8 +1053,8 @@ class PlayerWindow(QMainWindow): ...@@ -850,8 +1053,8 @@ class PlayerWindow(QMainWindow):
if file_path: if file_path:
self.play_video(file_path) self.play_video(file_path)
def play_video(self, file_path: str, template_data: Dict[str, Any] = None): def play_video(self, file_path: str, template_data: Dict[str, Any] = None, template_name: str = None):
"""Play video file with optional overlay data""" """Play video file with optional overlay data and template"""
try: try:
logger.info(f"PlayerWindow.play_video() called with: {file_path}") logger.info(f"PlayerWindow.play_video() called with: {file_path}")
logger.info(f"Media player state before play: {self.media_player.playbackState()}") logger.info(f"Media player state before play: {self.media_player.playbackState()}")
...@@ -898,6 +1101,15 @@ class PlayerWindow(QMainWindow): ...@@ -898,6 +1101,15 @@ class PlayerWindow(QMainWindow):
self.media_player.setSource(url) self.media_player.setSource(url)
logger.info(f"Media player source set to: {url.toString()}") 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 # Update overlay with video info using safe method
overlay_data = template_data or {} overlay_data = template_data or {}
overlay_data.update({ overlay_data.update({
...@@ -1097,8 +1309,10 @@ class PlayerWindow(QMainWindow): ...@@ -1097,8 +1309,10 @@ class PlayerWindow(QMainWindow):
current_time = time.strftime("%H:%M:%S") current_time = time.strftime("%H:%M:%S")
if hasattr(self, 'window_overlay'): if hasattr(self, 'window_overlay'):
overlay_view = self.window_overlay overlay_view = self.window_overlay
# Update overlay safely - handles both native and WebEngine # Only update if we have valid overlay data
self._update_overlay_safe(overlay_view, {'currentTime': current_time}) 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: except Exception as e:
logger.error(f"Periodic overlay update failed: {e}") logger.error(f"Periodic overlay update failed: {e}")
...@@ -1198,18 +1412,69 @@ class PlayerWindow(QMainWindow): ...@@ -1198,18 +1412,69 @@ class PlayerWindow(QMainWindow):
"""Check if this is a native overlay""" """Check if this is a native overlay"""
return isinstance(overlay_view, NativeOverlayWidget) 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): def _update_overlay_safe(self, overlay_view, data):
"""Update overlay data safely, handling both native and WebEngine overlays""" """Update overlay data safely, handling both native and WebEngine overlays"""
try: 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): if self._is_native_overlay(overlay_view):
# Native overlay - always ready, update immediately # 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") logger.debug("Native overlay updated successfully")
return True return True
elif isinstance(overlay_view, OverlayWebView): elif isinstance(overlay_view, OverlayWebView):
# WebEngine overlay - check readiness first # WebEngine overlay - check readiness first
if self._is_webengine_ready(overlay_view): 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") logger.debug("WebEngine overlay updated successfully")
return True return True
else: else:
...@@ -1509,15 +1774,76 @@ class QtVideoPlayer(QObject): ...@@ -1509,15 +1774,76 @@ class QtVideoPlayer(QObject):
logger.debug(f"WebEngine readiness check failed: {e}") logger.debug(f"WebEngine readiness check failed: {e}")
return False 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): def _send_safe_overlay_update(self, overlay_view, data):
"""Send overlay update with additional safety checks""" """Send overlay update with additional safety checks"""
try: try:
if not self._is_webengine_ready(overlay_view): # Clean data before sending to prevent null property issues
logger.debug("WebEngine lost readiness, skipping update") cleaned_data = self._clean_overlay_data(data)
if not cleaned_data:
logger.debug("No valid data to send to overlay after cleaning")
return return
overlay_view.update_overlay_data(data) if hasattr(self, 'window') and self.window:
logger.info("Default WebEngine overlay loaded successfully") # 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: except Exception as e:
logger.error(f"Failed to send safe overlay update: {e}") logger.error(f"Failed to send safe overlay update: {e}")
...@@ -1743,11 +2069,11 @@ class QtVideoPlayer(QObject): ...@@ -1743,11 +2069,11 @@ class QtVideoPlayer(QObject):
try: try:
file_path = message.data.get("file_path") file_path = message.data.get("file_path")
template_data = message.data.get("overlay_data", {}) 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"VIDEO_PLAY message received from {message.sender}")
logger.info(f"File path: {file_path}") 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}") logger.info(f"Overlay data: {template_data}")
if not file_path: if not file_path:
...@@ -1767,8 +2093,8 @@ class QtVideoPlayer(QObject): ...@@ -1767,8 +2093,8 @@ class QtVideoPlayer(QObject):
logger.info(f"Handler is main thread: {threading.current_thread() is threading.main_thread()}") 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!") logger.info("CALLING play_video() DIRECTLY on Qt main thread - no cross-thread issues!")
# Direct call - we're already on the main thread! # Direct call - we're already on the main thread! Pass template name
self.window.play_video(file_path, template_data) self.window.play_video(file_path, template_data, template_name)
logger.info("play_video() called successfully on main thread") logger.info("play_video() called successfully on main thread")
except Exception as e: except Exception as e:
...@@ -1840,12 +2166,36 @@ class QtVideoPlayer(QObject): ...@@ -1840,12 +2166,36 @@ class QtVideoPlayer(QObject):
def _handle_template_change(self, message: Message): def _handle_template_change(self, message: Message):
"""Handle template change message - now running on Qt main thread""" """Handle template change message - now running on Qt main thread"""
try: try:
template_name = message.data.get("template_name", "")
template_data = message.data.get("template_data", {}) 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'): if self.window and hasattr(self.window, 'window_overlay'):
# Direct call - we're already on the main thread!
overlay_view = 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: except Exception as e:
logger.error(f"Failed to handle template change: {e}") logger.error(f"Failed to handle template change: {e}")
...@@ -1856,9 +2206,13 @@ class QtVideoPlayer(QObject): ...@@ -1856,9 +2206,13 @@ class QtVideoPlayer(QObject):
overlay_data = message.data.get("overlay_data", {}) overlay_data = message.data.get("overlay_data", {})
if self.window and overlay_data and hasattr(self.window, 'window_overlay'): if self.window and overlay_data and hasattr(self.window, 'window_overlay'):
# Direct call - we're already on the main thread! # Validate and clean overlay_data before sending to overlay
overlay_view = self.window.window_overlay cleaned_data = self._clean_overlay_data(overlay_data)
self.window._update_overlay_safe(overlay_view, 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: except Exception as e:
logger.error(f"Failed to handle overlay update: {e}") logger.error(f"Failed to handle overlay update: {e}")
......
...@@ -7,7 +7,7 @@ import time ...@@ -7,7 +7,7 @@ import time
import logging import logging
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QStackedWidget QLabel, QPushButton, QSlider, QFrame, QStackedWidget
...@@ -24,11 +24,13 @@ from PyQt6.QtGui import ( ...@@ -24,11 +24,13 @@ from PyQt6.QtGui import (
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget from PyQt6.QtMultimediaWidgets import QVideoWidget
from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebEngineCore import QWebEngineProfile
from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtWebChannel import QWebChannel
from ..core.thread_manager import ThreadedComponent from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import QtConfig from ..config.settings import QtConfig
from .overlay_url_handler import OverlayUrlSchemeHandler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -146,8 +148,21 @@ class OverlayWebView(QWebEngineView): ...@@ -146,8 +148,21 @@ class OverlayWebView(QWebEngineView):
super().__init__(parent) super().__init__(parent)
self.web_channel = None self.web_channel = None
self.overlay_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() 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): def setup_web_view(self):
"""Setup web view for transparent overlays""" """Setup web view for transparent overlays"""
...@@ -165,13 +180,122 @@ class OverlayWebView(QWebEngineView): ...@@ -165,13 +180,122 @@ class OverlayWebView(QWebEngineView):
self.web_channel.registerObject("overlay", self.overlay_channel) self.web_channel.registerObject("overlay", self.overlay_channel)
page.setWebChannel(self.web_channel) page.setWebChannel(self.web_channel)
# Load overlay HTML # Load default template
overlay_path = Path(__file__).parent / "overlay.html" self.load_template(self.current_template)
if overlay_path.exists():
self.load(QUrl.fromLocalFile(str(overlay_path))) def _setup_custom_scheme(self):
logger.info(f"Loaded overlay HTML: {overlay_path}") """Setup custom URL scheme handler for overlay resources"""
else: try:
logger.error(f"Overlay HTML not found: {overlay_path}") # 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: def get_overlay_channel(self) -> OverlayWebChannel:
"""Get the overlay communication channel""" """Get the overlay communication channel"""
...@@ -501,13 +625,22 @@ class PlayerWindow(QMainWindow): ...@@ -501,13 +625,22 @@ class PlayerWindow(QMainWindow):
if file_path: if file_path:
self.play_video(file_path) self.play_video(file_path)
def play_video(self, file_path: str, template_data: Dict[str, Any] = None): def play_video(self, file_path: str, template_data: Dict[str, Any] = None, template_name: str = None):
"""Play video file with optional overlay data""" """Play video file with optional overlay data and template"""
try: try:
with QMutexLocker(self.mutex): with QMutexLocker(self.mutex):
url = QUrl.fromLocalFile(str(Path(file_path).absolute())) url = QUrl.fromLocalFile(str(Path(file_path).absolute()))
self.media_player.setSource(url) 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 # Update overlay with video info
overlay_data = template_data or {} overlay_data = template_data or {}
overlay_data.update({ overlay_data.update({
...@@ -515,14 +648,14 @@ class PlayerWindow(QMainWindow): ...@@ -515,14 +648,14 @@ class PlayerWindow(QMainWindow):
'subtitle': 'MbetterClient PyQt6 Player' '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: if self.settings.auto_play:
self.media_player.play() self.media_player.play()
# Start background metadata extraction # Start background metadata extraction
worker = VideoProcessingWorker( worker = VideoProcessingWorker(
"metadata_extraction", "metadata_extraction",
{"file_path": file_path}, {"file_path": file_path},
self.on_metadata_extracted self.on_metadata_extracted
) )
...@@ -835,15 +968,17 @@ class Qt6VideoPlayer(ThreadedComponent): ...@@ -835,15 +968,17 @@ class Qt6VideoPlayer(ThreadedComponent):
try: try:
file_path = message.data.get("file_path") file_path = message.data.get("file_path")
template_data = message.data.get("overlay_data", {}) template_data = message.data.get("overlay_data", {})
template_name = message.data.get("template") # Extract template name from message
if not file_path: if not file_path:
logger.error("No file path provided for video play") logger.error("No file path provided for video play")
return return
logger.info(f"Playing video: {file_path}") logger.info(f"Playing video: {file_path}")
logger.info(f"Template name: {template_name}")
if self.window: 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: except Exception as e:
logger.error(f"Failed to handle video play: {e}") logger.error(f"Failed to handle video play: {e}")
...@@ -880,10 +1015,20 @@ class Qt6VideoPlayer(ThreadedComponent): ...@@ -880,10 +1015,20 @@ class Qt6VideoPlayer(ThreadedComponent):
"""Handle template change message""" """Handle template change message"""
try: try:
template_data = message.data.get("template_data", {}) 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 = 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: except Exception as e:
logger.error(f"Failed to handle template change: {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 @@ ...@@ -292,458 +292,18 @@
<div class="progress-bar" id="progressBar" style="width: 0%;"></div> <div class="progress-bar" id="progressBar" style="width: 0%;"></div>
</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 src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script> <script src="overlay://overlay.js"></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>
</body> </body>
</html> </html>
\ No newline at end of file
...@@ -5,6 +5,7 @@ import os ...@@ -5,6 +5,7 @@ import os
import logging import logging
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from flask import request, jsonify, g from flask import request, jsonify, g
...@@ -194,46 +195,101 @@ class DashboardAPI: ...@@ -194,46 +195,101 @@ class DashboardAPI:
return {"error": str(e)} return {"error": str(e)}
def update_overlay(self, template: str, data: Dict[str, Any]) -> Dict[str, Any]: def update_overlay(self, template: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Update video overlay""" """Update video overlay and load specific template"""
try: 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( message = MessageBuilder.template_change(
sender="web_dashboard", sender="web_dashboard",
template_name=template, template_name=template,
template_data=data template_data=template_data
) )
message.recipient = "qt_player" message.recipient = "qt_player"
self.message_bus.publish(message) self.message_bus.publish(message)
logger.info(f"Overlay update sent: {template}") # Also send a separate overlay update message to ensure data is updated
return {"success": True, "template": template} 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: except Exception as e:
logger.error(f"Overlay update error: {e}") logger.error(f"Overlay update error: {e}")
return {"error": str(e)} return {"error": str(e)}
def get_templates(self) -> Dict[str, Any]: def get_templates(self) -> Dict[str, Any]:
"""Get available overlay templates""" """Get available overlay templates from both built-in and uploaded directories"""
try: try:
# This would normally query the template system from pathlib import Path
templates = {
"news_template": { # Get built-in templates directory
"name": "News Template", builtin_templates_dir = Path(__file__).parent.parent / "qt_player" / "templates"
"description": "Breaking news with scrolling text",
"fields": ["headline", "ticker_text", "logo_url"] # Get persistent uploaded templates directory
}, uploaded_templates_dir = self._get_persistent_templates_dir()
"sports_template": {
"name": "Sports Template", template_list = []
"description": "Sports scores and updates", templates_seen = set()
"fields": ["team1", "team2", "score1", "score2", "event"]
}, # First scan uploaded templates (these take priority)
"simple_template": { if uploaded_templates_dir.exists():
"name": "Simple Template", uploaded_files = list(uploaded_templates_dir.glob("*.html"))
"description": "Basic text overlay", for html_file in uploaded_files:
"fields": ["title", "subtitle", "text"] template_name = html_file.stem
} template_list.append({
} "name": template_name,
"filename": html_file.name,
return {"templates": templates} "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: except Exception as e:
logger.error(f"Failed to get templates: {e}") logger.error(f"Failed to get templates: {e}")
...@@ -581,6 +637,105 @@ class DashboardAPI: ...@@ -581,6 +637,105 @@ class DashboardAPI:
except Exception as e: except Exception as e:
logger.error(f"Video deletion error: {e}") logger.error(f"Video deletion error: {e}")
return {"error": str(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 # Route functions for Flask
......
...@@ -7,6 +7,7 @@ from datetime import datetime ...@@ -7,6 +7,7 @@ from datetime import datetime
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, session 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 flask_login import login_required, current_user, login_user, logout_user
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from werkzeug.utils import secure_filename
from .auth import AuthenticatedUser from .auth import AuthenticatedUser
from ..core.message_bus import Message, MessageType from ..core.message_bus import Message, MessageType
...@@ -665,4 +666,77 @@ def shutdown_application(): ...@@ -665,4 +666,77 @@ def shutdown_application():
except Exception as e: except Exception as e:
logger.error(f"API shutdown error: {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 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 @@ ...@@ -245,9 +245,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Template</label> <label class="form-label">Template</label>
<select class="form-select" id="video-template"> <select class="form-select" id="video-template">
<option value="news_template">News Template</option> <option value="">Loading templates...</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
</select> </select>
</div> </div>
</form> </form>
...@@ -274,9 +272,7 @@ ...@@ -274,9 +272,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Template</label> <label class="form-label">Template</label>
<select class="form-select" id="overlay-template"> <select class="form-select" id="overlay-template">
<option value="news_template">News Template</option> <option value="">Loading templates...</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
...@@ -305,6 +301,9 @@ ...@@ -305,6 +301,9 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Load available templates on page load
loadAvailableTemplates();
// Quick action buttons // Quick action buttons
document.getElementById('btn-play-video').addEventListener('click', function() { document.getElementById('btn-play-video').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show(); new bootstrap.Modal(document.getElementById('playVideoModal')).show();
...@@ -465,5 +464,65 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -465,5 +464,65 @@ document.addEventListener('DOMContentLoaded', function() {
setInterval(updateSystemStatus, 30000); // 30 seconds setInterval(updateSystemStatus, 30000); // 30 seconds
setInterval(updateVideoStatus, 10000); // 10 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> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -9,13 +9,32 @@ ...@@ -9,13 +9,32 @@
<h1>Template Management</h1> <h1>Template Management</h1>
<p>Manage video overlay templates and create custom designs.</p> <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 --> <!-- Template List -->
<div class="card mb-4"> <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> <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>
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
...@@ -23,8 +42,7 @@ ...@@ -23,8 +42,7 @@
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Category</th> <th>Source</th>
<th>Author</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
...@@ -51,44 +69,55 @@ ...@@ -51,44 +69,55 @@
</div> </div>
</div> </div>
<!-- Create Template Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="createTemplateModal" tabindex="-1" aria-labelledby="createTemplateModalLabel" aria-hidden="true"> <div class="modal fade" id="deleteTemplateModal" tabindex="-1" aria-labelledby="deleteTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="create-template-form"> <p>Are you sure you want to delete the template "<span id="delete-template-name"></span>"?</p>
<div class="mb-3"> <p class="text-muted">This action cannot be undone.</p>
<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>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <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>
</div> </div>
</div> </div>
<script> <script>
let templateToDelete = null;
// Load templates on page load // Load templates on page load
function loadTemplates() { function loadTemplates() {
fetch('/api/templates') fetch('/api/templates')
...@@ -97,91 +126,265 @@ ...@@ -97,91 +126,265 @@
const tbody = document.getElementById('templates-table-body'); const tbody = document.getElementById('templates-table-body');
tbody.innerHTML = ''; tbody.innerHTML = '';
data.forEach(template => { data.templates.forEach(template => {
const row = document.createElement('tr'); 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 = ` row.innerHTML = `
<td>${template.name}</td>
<td>${template.category}</td>
<td>${template.author || 'System'}</td>
<td> <td>
<button class="btn btn-sm btn-primary edit-template" data-id="${template.id}">Edit</button> <strong>${template.name}</strong>
<button class="btn btn-sm btn-info preview-template" data-id="${template.id}">Preview</button> ${template.source === 'uploaded' ? '<span class="badge bg-primary ms-2">Override</span>' : ''}
<button class="btn btn-sm btn-danger delete-template" data-id="${template.id}">Delete</button> </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> </td>
`; `;
tbody.appendChild(row); tbody.appendChild(row);
}); });
// Add event listeners to action buttons // Add event listeners to action buttons
document.querySelectorAll('.edit-template').forEach(btn => { document.querySelectorAll('.preview-template').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id'); const templateName = this.getAttribute('data-name');
editTemplate(templateId); previewTemplate(templateName);
}); });
}); });
document.querySelectorAll('.preview-template').forEach(btn => { document.querySelectorAll('.view-template').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id'); const templateName = this.getAttribute('data-name');
previewTemplate(templateId); viewTemplateCode(templateName);
}); });
}); });
document.querySelectorAll('.delete-template').forEach(btn => { document.querySelectorAll('.delete-template').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
const templateId = this.getAttribute('data-id'); const templateName = this.getAttribute('data-name');
deleteTemplate(templateId); showDeleteConfirmation(templateName);
}); });
}); });
}) })
.catch(error => { .catch(error => {
console.error('Error loading templates:', error); console.error('Error loading templates:', error);
showAlert('Error loading templates: ' + error.message, 'danger');
}); });
} }
// Preview template // Preview template in popup window
function previewTemplate(templateId) { function previewTemplate(templateName) {
const previewContainer = document.getElementById('template-preview'); // Open template in a new popup window
previewContainer.innerHTML = `<p>Loading preview for template ${templateId}...</p>`; const previewUrl = `/api/templates/${templateName}`;
// In a real implementation, this would show a visual preview of the template 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 // Show delete confirmation modal
function editTemplate(templateId) { function showDeleteConfirmation(templateName) {
alert(`Edit template ${templateId} - This would open the template editor`); templateToDelete = templateName;
document.getElementById('delete-template-name').textContent = templateName;
const modal = new bootstrap.Modal(document.getElementById('deleteTemplateModal'));
modal.show();
} }
// Delete template // Delete template
function deleteTemplate(templateId) { function deleteTemplate(templateName) {
if (confirm('Are you sure you want to delete this template?')) { fetch(`/api/templates/${templateName}`, {
// In a real implementation, this would delete the template method: 'DELETE'
alert(`Delete template ${templateId} - This would delete the template from the database`); })
} .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 // View template code in modal
document.getElementById('create-template-btn').addEventListener('click', function() { function viewTemplateCode(templateName) {
const name = document.getElementById('template-name').value; document.getElementById('view-template-name').textContent = templateName;
const category = document.getElementById('template-category').value;
const description = document.getElementById('template-description').value;
if (!name) { // Load template content
alert('Template name is required'); fetch(`/api/templates/${templateName}`)
return; .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 alertContainer.innerHTML = alertHtml;
alert(`Create template: ${name} (${category}) - This would save the template to the database`);
// Close modal and refresh template list // Auto-dismiss after 5 seconds
const modal = bootstrap.Modal.getInstance(document.getElementById('createTemplateModal')); setTimeout(() => {
modal.hide(); const alert = document.getElementById(alertId);
loadTemplates(); 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() { document.addEventListener('DOMContentLoaded', function() {
loadTemplates(); 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> </script>
{% endblock %} {% endblock %}
\ No newline at end of file
...@@ -38,9 +38,7 @@ ...@@ -38,9 +38,7 @@
<div class="mb-3"> <div class="mb-3">
<label for="template-select" class="form-label">Template</label> <label for="template-select" class="form-label">Template</label>
<select class="form-select" id="template-select"> <select class="form-select" id="template-select">
<option value="news_template">News Template</option> <option value="">Loading templates...</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
...@@ -56,6 +54,47 @@ ...@@ -56,6 +54,47 @@
</div> </div>
<script> <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 // Video control functions
document.getElementById('play-btn').addEventListener('click', function() { document.getElementById('play-btn').addEventListener('click', function() {
fetch('/api/video/control', { fetch('/api/video/control', {
...@@ -114,7 +153,7 @@ ...@@ -114,7 +153,7 @@
const template = document.getElementById('template-select').value; const template = document.getElementById('template-select').value;
const text = document.getElementById('overlay-text').value; const text = document.getElementById('overlay-text').value;
fetch('/api/overlay/update', { fetch('/api/overlay', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
......
...@@ -27,9 +27,7 @@ ...@@ -27,9 +27,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Template</label> <label class="form-label">Template</label>
<select class="form-select" id="video-template"> <select class="form-select" id="video-template">
<option value="news_template">News Template</option> <option value="">Loading templates...</option>
<option value="sports_template">Sports Template</option>
<option value="simple_template">Simple Template</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary" id="upload-btn"> <button type="submit" class="btn btn-primary" id="upload-btn">
...@@ -114,6 +112,9 @@ ...@@ -114,6 +112,9 @@
{% block scripts %} {% block scripts %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Load available templates on page load
loadAvailableTemplates();
// Elements // Elements
const uploadForm = document.getElementById('upload-form'); const uploadForm = document.getElementById('upload-form');
const videoFileInput = document.getElementById('video-file'); const videoFileInput = document.getElementById('video-file');
...@@ -130,6 +131,43 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -130,6 +131,43 @@ document.addEventListener('DOMContentLoaded', function() {
// Store uploaded videos // Store uploaded videos
let uploadedVideos = []; 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 // Handle form submission
uploadForm.addEventListener('submit', function(e) { uploadForm.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
......
...@@ -27,6 +27,7 @@ python-dotenv>=0.19.0 ...@@ -27,6 +27,7 @@ python-dotenv>=0.19.0
# Utilities and system # Utilities and system
psutil>=5.8.0 psutil>=5.8.0
click>=8.0.0 click>=8.0.0
watchdog>=3.0.0
# Video and image processing # Video and image processing
opencv-python>=4.5.0 opencv-python>=4.5.0
......
...@@ -70,8 +70,11 @@ def test_video_playback_native(): ...@@ -70,8 +70,11 @@ def test_video_playback_native():
'subtitle': 'Video should be VISIBLE underneath this overlay', '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.' '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() # Use the new separate window overlay
overlay_view.update_overlay_data(overlay_data) 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 # Play video after 2 seconds
QTimer.singleShot(2000, play_test_video) QTimer.singleShot(2000, play_test_video)
...@@ -117,16 +120,20 @@ def test_video_playback_webengine(): ...@@ -117,16 +120,20 @@ def test_video_playback_webengine():
# Wait for WebEngine to be ready before updating # Wait for WebEngine to be ready before updating
def update_overlay_when_ready(): def update_overlay_when_ready():
overlay_view = window.video_widget.get_overlay_view() # Use the new separate window overlay
if hasattr(overlay_view, 'overlay_channel') and overlay_view.overlay_channel: if hasattr(window, 'window_overlay') and window.window_overlay:
if window._is_webengine_ready(overlay_view): overlay_view = window.window_overlay
overlay_view.update_overlay_data(overlay_data) if hasattr(overlay_view, 'overlay_channel') and overlay_view.overlay_channel:
print("WebEngine overlay updated") 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: else:
print("WebEngine not ready, retrying...") overlay_view.update_overlay_data(overlay_data)
QTimer.singleShot(1000, update_overlay_when_ready)
else: else:
overlay_view.update_overlay_data(overlay_data) print("Warning: No window overlay available")
QTimer.singleShot(3000, update_overlay_when_ready) QTimer.singleShot(3000, update_overlay_when_ready)
...@@ -173,8 +180,11 @@ def test_uploaded_video(): ...@@ -173,8 +180,11 @@ def test_uploaded_video():
'subtitle': 'Testing uploaded video with native overlay', 'subtitle': 'Testing uploaded video with native overlay',
'ticker': 'This is a real uploaded video file. Video should be visible 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() # Use the new separate window overlay
overlay_view.update_overlay_data(overlay_data) 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) 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