Removed qt5 and add qt6

parent ace60ace
...@@ -31,19 +31,19 @@ PLATFORM_CONFIG = { ...@@ -31,19 +31,19 @@ PLATFORM_CONFIG = {
'executable_name': BUILD_CONFIG['app_name'] + '.exe', 'executable_name': BUILD_CONFIG['app_name'] + '.exe',
'icon_ext': '.ico', 'icon_ext': '.ico',
'additional_paths': [], 'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'], 'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb', 'PyQt5', 'PyQt5.QtCore', 'PyQt5.QtGui', 'PyQt5.QtWidgets', 'PyQt5.QtMultimedia', 'PyQt5.QtMultimediaWidgets'],
}, },
'Darwin': { # macOS 'Darwin': { # macOS
'executable_name': BUILD_CONFIG['app_name'], 'executable_name': BUILD_CONFIG['app_name'],
'icon_ext': '.icns', 'icon_ext': '.icns',
'additional_paths': [], 'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'], 'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb', 'PyQt5', 'PyQt5.QtCore', 'PyQt5.QtGui', 'PyQt5.QtWidgets', 'PyQt5.QtMultimedia', 'PyQt5.QtMultimediaWidgets'],
}, },
'Linux': { 'Linux': {
'executable_name': BUILD_CONFIG['app_name'], 'executable_name': BUILD_CONFIG['app_name'],
'icon_ext': '.png', 'icon_ext': '.png',
'additional_paths': [], 'additional_paths': [],
'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb'], 'exclude_modules': ['tkinter', 'unittest', 'doctest', 'pdb', 'PyQt5', 'PyQt5.QtCore', 'PyQt5.QtGui', 'PyQt5.QtWidgets', 'PyQt5.QtMultimedia', 'PyQt5.QtMultimediaWidgets'],
} }
} }
...@@ -109,18 +109,35 @@ def collect_data_files() -> List[tuple]: ...@@ -109,18 +109,35 @@ 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 overlay HTML and resources
qt_player_dir = project_root / 'mbetterclient' / 'qt_player'
if qt_player_dir.exists():
for file_path in qt_player_dir.rglob('*.html'):
if file_path.is_file():
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
# Include any CSS, JS, or other web resources
for ext in ['*.css', '*.js', '*.json']:
for file_path in qt_player_dir.rglob(ext):
if file_path.is_file():
relative_path = file_path.relative_to(project_root)
data_files.append((str(file_path), str(relative_path.parent)))
return data_files return data_files
def collect_hidden_imports() -> List[str]: def collect_hidden_imports() -> List[str]:
"""Collect hidden imports that PyInstaller might miss""" """Collect hidden imports that PyInstaller might miss"""
return [ return [
# PyQt5 modules # PyQt6 modules
'PyQt5.QtCore', 'PyQt6.QtCore',
'PyQt5.QtGui', 'PyQt6.QtGui',
'PyQt5.QtWidgets', 'PyQt6.QtWidgets',
'PyQt5.QtMultimedia', 'PyQt6.QtMultimedia',
'PyQt5.QtMultimediaWidgets', 'PyQt6.QtMultimediaWidgets',
'PyQt6.QtWebEngineWidgets',
'PyQt6.QtWebChannel',
# Flask and web dependencies # Flask and web dependencies
'flask', 'flask',
...@@ -289,7 +306,7 @@ def check_dependencies(): ...@@ -289,7 +306,7 @@ def check_dependencies():
# Map package names to their import names # Map package names to their import names
required_packages = { required_packages = {
'PyInstaller': 'PyInstaller', 'PyInstaller': 'PyInstaller',
'PyQt5': 'PyQt5' 'PyQt6': 'PyQt6'
} }
missing_packages = [] missing_packages = []
......
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Video Overlay</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background: transparent;
overflow: hidden;
width: 100vw;
height: 100vh;
position: relative;
}
.overlay-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
}
.title-main {
position: absolute;
top: 20%;
left: 50%;
transform: translateX(-50%);
font-size: 48px;
font-weight: bold;
color: white;
text-shadow:
2px 2px 4px rgba(0,0,0,0.8),
-1px -1px 0px rgba(0,0,0,0.5),
1px -1px 0px rgba(0,0,0,0.5),
-1px 1px 0px rgba(0,0,0,0.5),
1px 1px 0px rgba(0,0,0,0.5);
text-align: center;
opacity: 0;
animation: titleSlideIn 2s ease-out forwards;
max-width: 90%;
word-wrap: break-word;
}
.title-subtitle {
position: absolute;
top: 30%;
left: 50%;
transform: translateX(-50%);
font-size: 24px;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
text-align: center;
opacity: 0;
animation: titleSlideIn 2s ease-out 0.5s forwards;
max-width: 90%;
}
.news-ticker {
position: absolute;
bottom: 10%;
width: 100%;
background: linear-gradient(90deg,
rgba(220, 53, 69, 0.9) 0%,
rgba(220, 53, 69, 0.95) 50%,
rgba(220, 53, 69, 0.9) 100%);
color: white;
padding: 12px 0;
font-size: 18px;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
opacity: 0;
animation: fadeIn 1s ease-in 1s forwards;
}
.ticker-text {
display: inline-block;
animation: scroll 30s linear infinite;
padding-left: 100%;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
}
.time-display {
position: absolute;
top: 20px;
right: 20px;
font-size: 20px;
color: white;
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
opacity: 0;
animation: fadeIn 1s ease-in 2s forwards;
}
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
height: 4px;
background: linear-gradient(90deg, #007bff, #0056b3);
transition: width 0.3s ease;
opacity: 0.8;
}
.logo {
position: absolute;
top: 20px;
left: 20px;
width: 80px;
height: 80px;
opacity: 0;
animation: logoSlideIn 1.5s ease-out 1.5s forwards;
}
.stats-panel {
position: absolute;
top: 50%;
right: 20px;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.7);
border-radius: 10px;
padding: 20px;
color: white;
font-size: 16px;
opacity: 0;
animation: slideInRight 1s ease-out 2.5s forwards;
min-width: 200px;
}
.stats-item {
margin-bottom: 10px;
display: flex;
justify-content: space-between;
}
.canvas-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 500;
}
/* Animations */
@keyframes titleSlideIn {
0% {
opacity: 0;
transform: translateX(-50%) translateY(-50px);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
@keyframes logoSlideIn {
0% {
opacity: 0;
transform: translateX(-100px);
}
100% {
opacity: 0.9;
transform: translateX(0);
}
}
@keyframes slideInRight {
0% {
opacity: 0;
transform: translateY(-50%) translateX(100px);
}
100% {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
/* Responsive Design */
@media (max-width: 1200px) {
.title-main {
font-size: 36px;
}
.title-subtitle {
font-size: 20px;
}
.news-ticker {
font-size: 16px;
}
}
@media (max-width: 800px) {
.title-main {
font-size: 28px;
}
.title-subtitle {
font-size: 16px;
}
.stats-panel {
right: 10px;
font-size: 14px;
padding: 15px;
}
}
</style>
</head>
<body>
<div class="overlay-container">
<div class="logo" id="logo">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiByeD0iMTAiIGZpbGw9IiNkYzM1NDUiLz4KPHR0ZXh0IHg9IjQwIiB5PSI0NSIgZm9udC1mYW1pbHk9IkFyaWFsLCBzYW5zLXNlcmlmIiBmb250LXNpemU9IjI0IiBmb250LXdlaWdodD0iYm9sZCIgZmlsbD0id2hpdGUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuMzVlbSI+TTwvdGV4dD4KPC9zdmc+Cg==" alt="Logo">
</div>
<div class="title-main" id="titleMain">
MbetterClient Video Player
</div>
<div class="title-subtitle" id="titleSubtitle">
Ready for Content
</div>
<div class="time-display" id="timeDisplay">
00:00 / 00:00
</div>
<div class="news-ticker" id="newsTicker">
<div class="ticker-text" id="tickerText">
Welcome to MbetterClient • Professional Video Overlay System • Real-time Updates • Hardware Accelerated Playback
</div>
</div>
<div class="stats-panel" id="statsPanel" style="display: none;">
<div class="stats-item">
<span>Resolution:</span>
<span id="resolution">1920x1080</span>
</div>
<div class="stats-item">
<span>Bitrate:</span>
<span id="bitrate">5.2 Mbps</span>
</div>
<div class="stats-item">
<span>Codec:</span>
<span id="codec">H.264</span>
</div>
<div class="stats-item">
<span>FPS:</span>
<span id="fps">30.0</span>
</div>
</div>
<canvas class="canvas-overlay" id="canvasOverlay"></canvas>
<div class="progress-bar" id="progressBar" style="width: 0%;"></div>
</div>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script>
class OverlayManager {
constructor() {
this.channel = null;
this.overlayData = {};
this.canvas = document.getElementById('canvasOverlay');
this.ctx = this.canvas.getContext('2d');
this.animationFrame = null;
// Resize canvas to match window
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
// Initialize WebChannel
this.initWebChannel();
// Start canvas animations
this.startCanvasAnimations();
console.log('OverlayManager initialized');
}
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
initWebChannel() {
new QWebChannel(qt.webChannelTransport, (channel) => {
this.channel = channel;
// Register for updates from Python
if (channel.objects.overlay) {
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 successfully');
}
});
}
updateOverlay(data) {
console.log('Updating overlay with data:', data);
this.overlayData = { ...this.overlayData, ...data };
// Update title elements
if (data.title) {
document.getElementById('titleMain').textContent = data.title;
this.animateElement('titleMain', 'pulse');
}
if (data.subtitle) {
document.getElementById('titleSubtitle').textContent = data.subtitle;
this.animateElement('titleSubtitle', 'pulse');
}
// Update ticker text
if (data.ticker) {
document.getElementById('tickerText').textContent = data.ticker;
}
// Show/hide stats panel
if (data.showStats !== undefined) {
const statsPanel = document.getElementById('statsPanel');
statsPanel.style.display = data.showStats ? 'block' : 'none';
}
// Update custom CSS if provided
if (data.customCSS) {
this.applyCustomCSS(data.customCSS);
}
}
updateProgress(position, duration) {
const percentage = duration > 0 ? (position / duration) * 100 : 0;
document.getElementById('progressBar').style.width = percentage + '%';
// Format time display
const currentTime = this.formatTime(position);
const totalTime = this.formatTime(duration);
document.getElementById('timeDisplay').textContent = `${currentTime} / ${totalTime}`;
}
updateVideoInfo(info) {
console.log('Video info updated:', info);
if (info.resolution) {
document.getElementById('resolution').textContent = info.resolution;
}
if (info.bitrate) {
document.getElementById('bitrate').textContent = info.bitrate;
}
if (info.codec) {
document.getElementById('codec').textContent = info.codec;
}
if (info.fps) {
document.getElementById('fps').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);
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() {
const animate = () => {
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 when page loads
let overlayManager = null;
document.addEventListener('DOMContentLoaded', () => {
overlayManager = new OverlayManager();
});
// Cleanup on unload
window.addEventListener('beforeunload', () => {
if (overlayManager) {
overlayManager.cleanup();
}
});
// Expose to global scope for debugging
window.overlayManager = overlayManager;
</script>
</body>
</html>
\ No newline at end of file
""" """
Overlay engine for rendering dynamic overlays on video content Overlay engine for rendering dynamic overlays on video content
NOTE: This is legacy code - the new PyQt6 implementation uses QWebEngineView for overlays
""" """
import time import time
import logging import logging
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from pathlib import Path from pathlib import Path
from PyQt5.QtCore import Qt, QRect, QPoint, QTimer, QPropertyAnimation, QEasingCurve from PyQt6.QtCore import Qt, QRect, QPoint, QTimer, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import ( from PyQt6.QtGui import (
QPainter, QPen, QBrush, QColor, QFont, QPixmap, QFontMetrics, QPainter, QPen, QBrush, QColor, QFont, QPixmap, QFontMetrics,
QLinearGradient, QRadialGradient, QPolygon QLinearGradient, QRadialGradient, QPolygon
) )
from PyQt5.QtWidgets import QGraphicsEffect from PyQt6.QtWidgets import QGraphicsEffect
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
......
""" """
PyQt video player with overlay support PyQt6 Multi-threaded Video Player with QWebEngineView Overlay System
Replaces the previous PyQt5 implementation with enhanced functionality
""" """
import sys import sys
import time import time
import logging import logging
import json
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from PyQt5.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QGraphicsView, QGraphicsScene, QLabel, QPushButton, QSlider, QFrame, QStackedWidget
QGraphicsProxyWidget
) )
from PyQt5.QtCore import ( from PyQt6.QtCore import (
Qt, QTimer, QThread, pyqtSignal, QUrl, QRect, QPropertyAnimation, Qt, QTimer, QThread, pyqtSignal, QUrl, QRect, QPropertyAnimation,
QEasingCurve, QSequentialAnimationGroup QEasingCurve, QSequentialAnimationGroup, QObject, QMutex, QMutexLocker,
QThreadPool, QRunnable, pyqtSlot
) )
from PyQt5.QtGui import ( from PyQt6.QtGui import (
QFont, QPainter, QPen, QBrush, QColor, QPixmap, QMovie, QFont, QPainter, QPen, QBrush, QColor, QPixmap, QMovie,
QLinearGradient, QFontMetrics QLinearGradient, QFontMetrics, QAction
) )
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt5.QtMultimediaWidgets import QVideoWidget from PyQt6.QtMultimediaWidgets import QVideoWidget
from PyQt6.QtWebEngineWidgets import QWebEngineView
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_engine import OverlayEngine
from .templates import TemplateManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OverlayWidget(QWidget): class OverlayWebChannel(QObject):
"""Transparent overlay widget for rendering overlays on top of video""" """QObject for WebChannel communication with overlay HTML/JS"""
# Signals to send data to JavaScript
dataUpdated = pyqtSignal(dict)
positionChanged = pyqtSignal(float, float) # position, duration in seconds
videoInfoChanged = pyqtSignal(dict)
def __init__(self):
super().__init__()
self.mutex = QMutex()
self.overlay_data = {}
logger.info("OverlayWebChannel initialized")
@pyqtSlot(str)
def updateTitle(self, title: str):
"""Update main title from JavaScript"""
with QMutexLocker(self.mutex):
self.overlay_data['title'] = title
self.dataUpdated.emit({'title': title})
logger.debug(f"Title updated: {title}")
@pyqtSlot(str)
def updateSubtitle(self, subtitle: str):
"""Update subtitle from JavaScript"""
with QMutexLocker(self.mutex):
self.overlay_data['subtitle'] = subtitle
self.dataUpdated.emit({'subtitle': subtitle})
logger.debug(f"Subtitle updated: {subtitle}")
@pyqtSlot(bool)
def toggleStats(self, show: bool):
"""Toggle stats panel visibility"""
with QMutexLocker(self.mutex):
self.overlay_data['showStats'] = show
self.dataUpdated.emit({'showStats': show})
logger.debug(f"Stats panel toggled: {show}")
def send_data_update(self, data: Dict[str, Any]):
"""Send data update to JavaScript (thread-safe)"""
with QMutexLocker(self.mutex):
self.overlay_data.update(data)
self.dataUpdated.emit(data)
def send_position_update(self, position: float, duration: float):
"""Send playback position update to JavaScript (thread-safe)"""
self.positionChanged.emit(position, duration)
def send_video_info(self, info: Dict[str, Any]):
"""Send video information to JavaScript (thread-safe)"""
self.videoInfoChanged.emit(info)
class VideoProcessingWorker(QRunnable):
"""Background worker for video processing tasks"""
def __init__(self, task_type: str, data: Dict[str, Any], callback=None):
super().__init__()
self.task_type = task_type
self.data = data
self.callback = callback
self.setAutoDelete(True)
def run(self):
"""Execute the video processing task"""
try:
logger.debug(f"Processing video task: {self.task_type}")
if self.task_type == "metadata_extraction":
result = self._extract_metadata()
elif self.task_type == "thumbnail_generation":
result = self._generate_thumbnail()
elif self.task_type == "overlay_rendering":
result = self._render_overlay()
else:
result = {"error": f"Unknown task type: {self.task_type}"}
if self.callback:
self.callback(result)
except Exception as e:
logger.error(f"Video processing worker error: {e}")
if self.callback:
self.callback({"error": str(e)})
def _extract_metadata(self) -> Dict[str, Any]:
"""Extract video metadata"""
# Placeholder for metadata extraction
return {
"resolution": "1920x1080",
"bitrate": "5.2 Mbps",
"codec": "H.264",
"fps": "30.0"
}
def _generate_thumbnail(self) -> Dict[str, Any]:
"""Generate video thumbnail"""
# Placeholder for thumbnail generation
return {"thumbnail_path": "/tmp/thumbnail.jpg"}
def _render_overlay(self) -> Dict[str, Any]:
"""Render overlay elements"""
# Placeholder for overlay rendering
return {"rendered": True}
class OverlayWebView(QWebEngineView):
"""Custom QWebEngineView for video overlays with transparent background"""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.overlay_engine = None self.web_channel = None
self.setAttribute(Qt.WA_TransparentForMouseEvents, False) # Allow mouse events self.overlay_channel = None
self.setStyleSheet("background-color: transparent;") self.setup_web_view()
logger.info("OverlayWebView initialized")
# Timer to force repaints for overlay rendering
self.repaint_timer = QTimer() def setup_web_view(self):
self.repaint_timer.timeout.connect(self.update) """Setup web view for transparent overlays"""
self.repaint_timer.start(33) # ~30 FPS for smooth overlays # Enable transparency
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
logger.info("OverlayWidget initialized with repaint timer") self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
def set_overlay_engine(self, overlay_engine): # Configure page settings
"""Set overlay engine for rendering""" page = self.page()
self.overlay_engine = overlay_engine page.setBackgroundColor(QColor(0, 0, 0, 0)) # Transparent background
logger.info("Overlay engine set on overlay widget")
def paintEvent(self, event):
"""Custom paint event to render overlays"""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Render overlays if available
if self.overlay_engine:
try:
element_count = len(self.overlay_engine.renderer.elements)
if element_count > 0:
logger.debug(f"OverlayWidget paintEvent: Rendering {element_count} overlay elements")
self.overlay_engine.render(painter, self.rect())
else:
logger.debug("OverlayWidget paintEvent: No overlay elements to render")
except Exception as e:
logger.error(f"Overlay rendering error: {e}")
import traceback
logger.error(f"Overlay rendering traceback: {traceback.format_exc()}")
else:
logger.debug("OverlayWidget paintEvent: No overlay engine available")
painter.end() # Setup WebChannel
self.web_channel = QWebChannel()
self.overlay_channel = OverlayWebChannel()
self.web_channel.registerObject("overlay", self.overlay_channel)
page.setWebChannel(self.web_channel)
# Load overlay HTML (handle both development and PyInstaller bundle modes)
overlay_path = self._get_overlay_path()
if overlay_path and overlay_path.exists():
self.load(QUrl.fromLocalFile(str(overlay_path)))
logger.info(f"Loaded overlay HTML: {overlay_path}")
else:
logger.error(f"Overlay HTML not found: {overlay_path}")
# Load fallback minimal overlay
self._load_fallback_overlay()
def _get_overlay_path(self) -> Optional[Path]:
"""Get overlay HTML path, handling PyInstaller bundle mode"""
try:
# Check if running as PyInstaller bundle
if hasattr(sys, '_MEIPASS'):
# Running as bundled executable
bundle_path = Path(sys._MEIPASS)
overlay_path = bundle_path / "mbetterclient" / "qt_player" / "overlay.html"
logger.debug(f"Bundle mode - checking overlay at: {overlay_path}")
return overlay_path
else:
# Running in development mode
overlay_path = Path(__file__).parent / "overlay.html"
logger.debug(f"Development mode - checking overlay at: {overlay_path}")
return overlay_path
except Exception as e:
logger.error(f"Failed to determine overlay path: {e}")
return None
def _load_fallback_overlay(self):
"""Load minimal fallback overlay if main overlay file not found"""
try:
fallback_html = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
padding: 20px;
background: transparent;
color: white;
font-family: Arial, sans-serif;
}
.overlay-content {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0,0,0,0.7);
padding: 10px;
border-radius: 5px;
}
</style>
</head>
<body>
<div class="overlay-content">
<h2>MbetterClient PyQt6 Player</h2>
<p>Overlay System Active</p>
</div>
</body>
</html>
"""
self.setHtml(fallback_html)
logger.info("Loaded fallback overlay HTML")
except Exception as e:
logger.error(f"Failed to load fallback overlay: {e}")
def get_overlay_channel(self) -> OverlayWebChannel:
"""Get the overlay communication channel"""
return self.overlay_channel
def update_overlay_data(self, data: Dict[str, Any]):
"""Update overlay with new data"""
if self.overlay_channel:
self.overlay_channel.send_data_update(data)
def update_position(self, position: float, duration: float):
"""Update playback position"""
if self.overlay_channel:
self.overlay_channel.send_position_update(position, duration)
def update_video_info(self, info: Dict[str, Any]):
"""Update video information"""
if self.overlay_channel:
self.overlay_channel.send_video_info(info)
class VideoWidget(QWidget): class VideoWidget(QWidget):
"""Composite video widget with overlay support that always works""" """Composite video widget with QWebEngineView overlay"""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.overlay_engine = None self.setup_ui()
logger.info("VideoWidget initialized")
def setup_ui(self):
"""Setup video widget with overlay"""
self.setStyleSheet("background-color: black;") self.setStyleSheet("background-color: black;")
# Create layout # Create layout
...@@ -91,47 +274,52 @@ class VideoWidget(QWidget): ...@@ -91,47 +274,52 @@ class VideoWidget(QWidget):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
# Create stacked widget for layering
self.stacked_widget = QStackedWidget()
# Create video widget for actual video playback # Create video widget for actual video playback
self.video_widget = QVideoWidget() self.video_widget = QVideoWidget()
self.video_widget.setStyleSheet("background-color: black;") self.video_widget.setStyleSheet("background-color: black;")
layout.addWidget(self.video_widget) self.stacked_widget.addWidget(self.video_widget)
# Create overlay widget that sits on top # Create overlay web view
self.overlay_widget = OverlayWidget() self.overlay_view = OverlayWebView()
self.overlay_widget.setParent(self) self.stacked_widget.addWidget(self.overlay_view)
logger.info("Composite VideoWidget initialized") layout.addWidget(self.stacked_widget)
# Ensure overlay is on top by setting the current widget
self.stacked_widget.setCurrentWidget(self.overlay_view)
def get_video_widget(self) -> QVideoWidget:
"""Get the video widget for media player"""
return self.video_widget
def set_overlay_engine(self, overlay_engine): def get_overlay_view(self) -> OverlayWebView:
"""Set overlay engine for rendering""" """Get the overlay web view"""
self.overlay_engine = overlay_engine return self.overlay_view
self.overlay_widget.set_overlay_engine(overlay_engine)
logger.info("Overlay engine set on composite video widget")
def resizeEvent(self, event): def resizeEvent(self, event):
"""Ensure overlay widget covers the entire video widget""" """Handle resize events"""
super().resizeEvent(event) super().resizeEvent(event)
self.overlay_widget.resize(self.size()) # Both widgets should automatically resize with the stacked widget
self.overlay_widget.move(0, 0)
def get_video_widget(self):
"""Get the actual QVideoWidget for media player"""
return self.video_widget
class PlayerControlsWidget(QWidget): class PlayerControlsWidget(QWidget):
"""Video player controls""" """Enhanced video player controls with thread-safe operations"""
play_pause_clicked = pyqtSignal() play_pause_clicked = pyqtSignal()
stop_clicked = pyqtSignal() stop_clicked = pyqtSignal()
seek_requested = pyqtSignal(int) seek_requested = pyqtSignal(int)
volume_changed = pyqtSignal(int)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.mutex = QMutex()
self.setup_ui() self.setup_ui()
def setup_ui(self): def setup_ui(self):
"""Setup control UI""" """Setup enhanced control UI"""
layout = QHBoxLayout(self) layout = QHBoxLayout(self)
layout.setContentsMargins(10, 5, 10, 5) layout.setContentsMargins(10, 5, 10, 5)
...@@ -146,71 +334,96 @@ class PlayerControlsWidget(QWidget): ...@@ -146,71 +334,96 @@ class PlayerControlsWidget(QWidget):
self.stop_btn.clicked.connect(self.stop_clicked.emit) self.stop_btn.clicked.connect(self.stop_clicked.emit)
# Position slider # Position slider
self.position_slider = QSlider(Qt.Horizontal) self.position_slider = QSlider(Qt.Orientation.Horizontal)
self.position_slider.setMinimum(0) self.position_slider.setMinimum(0)
self.position_slider.setMaximum(100) self.position_slider.setMaximum(100)
self.position_slider.sliderPressed.connect(self._on_slider_pressed) self.position_slider.sliderPressed.connect(self._on_slider_pressed)
self.position_slider.sliderReleased.connect(self._on_slider_released) self.position_slider.sliderReleased.connect(self._on_slider_released)
# Volume slider
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
self.volume_slider.setMinimum(0)
self.volume_slider.setMaximum(100)
self.volume_slider.setValue(100)
self.volume_slider.setMaximumWidth(100)
self.volume_slider.valueChanged.connect(self.volume_changed.emit)
# Time labels # Time labels
self.current_time_label = QLabel("00:00") self.current_time_label = QLabel("00:00")
self.duration_label = QLabel("00:00") self.duration_label = QLabel("00:00")
# Volume label
volume_label = QLabel("🔊")
# Layout # Layout
layout.addWidget(self.play_pause_btn) layout.addWidget(self.play_pause_btn)
layout.addWidget(self.stop_btn) layout.addWidget(self.stop_btn)
layout.addWidget(self.current_time_label) layout.addWidget(self.current_time_label)
layout.addWidget(self.position_slider, 1) # Stretch layout.addWidget(self.position_slider, 1) # Stretch
layout.addWidget(self.duration_label) layout.addWidget(self.duration_label)
layout.addWidget(volume_label)
layout.addWidget(self.volume_slider)
# Style # Enhanced styling
self.setStyleSheet(""" self.setStyleSheet("""
QWidget { QWidget {
background-color: rgba(0, 0, 0, 180); background-color: rgba(0, 0, 0, 200);
color: white; color: white;
} }
QPushButton { QPushButton {
border: 1px solid #555; border: 1px solid #555;
border-radius: 3px; border-radius: 5px;
background-color: #333; background-color: #333;
font-weight: bold;
} }
QPushButton:hover { QPushButton:hover {
background-color: #555; background-color: #555;
} }
QPushButton:pressed {
background-color: #777;
}
QSlider::groove:horizontal { QSlider::groove:horizontal {
border: 1px solid #555; border: 1px solid #555;
height: 4px; height: 6px;
background: #222; background: #222;
border-radius: 3px;
} }
QSlider::handle:horizontal { QSlider::handle:horizontal {
background: #fff; background: #fff;
border: 1px solid #555; border: 2px solid #555;
width: 12px; width: 14px;
margin: -4px 0; margin: -5px 0;
border-radius: 6px; border-radius: 7px;
}
QSlider::handle:horizontal:hover {
background: #ddd;
} }
""") """)
self.slider_pressed = False self.slider_pressed = False
def _on_slider_pressed(self): def _on_slider_pressed(self):
self.slider_pressed = True with QMutexLocker(self.mutex):
self.slider_pressed = True
def _on_slider_released(self): def _on_slider_released(self):
self.slider_pressed = False with QMutexLocker(self.mutex):
self.seek_requested.emit(self.position_slider.value()) self.slider_pressed = False
self.seek_requested.emit(self.position_slider.value())
def update_position(self, position: int, duration: int): def update_position(self, position: int, duration: int):
"""Update position display""" """Update position display (thread-safe)"""
if not self.slider_pressed and duration > 0: with QMutexLocker(self.mutex):
self.position_slider.setValue(int(position * 100 / duration)) if not self.slider_pressed and duration > 0:
self.position_slider.setValue(int(position * 100 / duration))
self.current_time_label.setText(self._format_time(position))
self.duration_label.setText(self._format_time(duration)) self.current_time_label.setText(self._format_time(position))
self.duration_label.setText(self._format_time(duration))
def update_play_pause_button(self, is_playing: bool): def update_play_pause_button(self, is_playing: bool):
"""Update play/pause button state""" """Update play/pause button state (thread-safe)"""
self.play_pause_btn.setText("⏸️" if is_playing else "▶️") with QMutexLocker(self.mutex):
self.play_pause_btn.setText("⏸️" if is_playing else "▶️")
def _format_time(self, ms: int) -> str: def _format_time(self, ms: int) -> str:
"""Format time in milliseconds to MM:SS""" """Format time in milliseconds to MM:SS"""
...@@ -221,27 +434,28 @@ class PlayerControlsWidget(QWidget): ...@@ -221,27 +434,28 @@ class PlayerControlsWidget(QWidget):
class PlayerWindow(QMainWindow): class PlayerWindow(QMainWindow):
"""Main player window""" """Enhanced main player window with thread-safe operations"""
def __init__(self, settings: QtConfig, overlay_engine: OverlayEngine): # Signals for thread communication
position_changed = pyqtSignal(int, int)
video_loaded = pyqtSignal(str)
def __init__(self, settings: QtConfig):
super().__init__() super().__init__()
self.settings = settings self.settings = settings
self.overlay_engine = overlay_engine self.mutex = QMutex()
self.thread_pool = QThreadPool()
self.thread_pool.setMaxThreadCount(4)
self.setup_ui() self.setup_ui()
self.setup_media_player() self.setup_media_player()
self.setup_timers()
# Auto-hide controls timer logger.info("PlayerWindow initialized")
self.controls_timer = QTimer()
self.controls_timer.timeout.connect(self.hide_controls)
self.controls_timer.setSingleShot(True)
# Mouse tracking for showing controls
self.setMouseTracking(True)
self.controls_visible = True
def setup_ui(self): def setup_ui(self):
"""Setup window UI""" """Setup enhanced window UI"""
self.setWindowTitle("MbetterClient Player") self.setWindowTitle("MbetterClient - PyQt6 Video Player")
self.setStyleSheet("background-color: black;") self.setStyleSheet("background-color: black;")
# Central widget # Central widget
...@@ -253,18 +467,16 @@ class PlayerWindow(QMainWindow): ...@@ -253,18 +467,16 @@ class PlayerWindow(QMainWindow):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
# Video widget # Video widget with overlay
self.video_widget = VideoWidget() self.video_widget = VideoWidget()
self.video_widget.set_overlay_engine(self.overlay_engine)
layout.addWidget(self.video_widget, 1) # Stretch layout.addWidget(self.video_widget, 1) # Stretch
logger.info(f"Composite video widget created with overlay engine: {self.overlay_engine is not None}")
# Controls # Controls
self.controls = PlayerControlsWidget() self.controls = PlayerControlsWidget()
self.controls.play_pause_clicked.connect(self.toggle_play_pause) self.controls.play_pause_clicked.connect(self.toggle_play_pause)
self.controls.stop_clicked.connect(self.stop_playback) self.controls.stop_clicked.connect(self.stop_playback)
self.controls.seek_requested.connect(self.seek_to_position) self.controls.seek_requested.connect(self.seek_to_position)
self.controls.volume_changed.connect(self.set_volume)
layout.addWidget(self.controls) layout.addWidget(self.controls)
# Window settings # Window settings
...@@ -275,77 +487,188 @@ class PlayerWindow(QMainWindow): ...@@ -275,77 +487,188 @@ class PlayerWindow(QMainWindow):
self.show() self.show()
if self.settings.always_on_top: if self.settings.always_on_top:
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Setup menu
self.setup_menu()
def setup_menu(self):
"""Setup application menu"""
menubar = self.menuBar()
# File menu
file_menu = menubar.addMenu('File')
open_action = QAction('Open Video', self)
open_action.triggered.connect(self.open_file_dialog)
file_menu.addAction(open_action)
# View menu
view_menu = menubar.addMenu('View')
fullscreen_action = QAction('Toggle Fullscreen', self)
fullscreen_action.setShortcut('F11')
fullscreen_action.triggered.connect(self.toggle_fullscreen)
view_menu.addAction(fullscreen_action)
stats_action = QAction('Toggle Stats', self)
stats_action.setShortcut('S')
stats_action.triggered.connect(self.toggle_stats)
view_menu.addAction(stats_action)
def setup_media_player(self): def setup_media_player(self):
"""Setup media player""" """Setup PyQt6 media player with audio output"""
self.media_player = QMediaPlayer(None, QMediaPlayer.VideoSurface) self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
self.media_player.setVideoOutput(self.video_widget.get_video_widget()) self.media_player.setVideoOutput(self.video_widget.get_video_widget())
# Connect signals # Connect signals
self.media_player.stateChanged.connect(self.on_state_changed) self.media_player.playbackStateChanged.connect(self.on_state_changed)
self.media_player.positionChanged.connect(self.on_position_changed) self.media_player.positionChanged.connect(self.on_position_changed)
self.media_player.durationChanged.connect(self.on_duration_changed) self.media_player.durationChanged.connect(self.on_duration_changed)
self.media_player.error.connect(self.on_media_error) self.media_player.errorOccurred.connect(self.on_media_error)
self.media_player.mediaStatusChanged.connect(self.on_media_status_changed)
# Set volume # Set volume
self.media_player.setVolume(int(self.settings.volume * 100)) self.audio_output.setVolume(self.settings.volume)
self.media_player.setMuted(self.settings.mute) self.audio_output.setMuted(self.settings.mute)
def setup_timers(self):
"""Setup various timers"""
# Auto-hide controls timer
self.controls_timer = QTimer()
self.controls_timer.timeout.connect(self.hide_controls)
self.controls_timer.setSingleShot(True)
# Overlay update timer
self.overlay_timer = QTimer()
self.overlay_timer.timeout.connect(self.update_overlay_periodically)
self.overlay_timer.start(1000) # Update every second
# Mouse tracking for showing controls
self.setMouseTracking(True)
self.controls_visible = True
def play_video(self, file_path: str): def open_file_dialog(self):
"""Play video file""" """Open file dialog to select video"""
from PyQt6.QtWidgets import QFileDialog
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Video File",
"",
"Video Files (*.mp4 *.avi *.mkv *.mov *.wmv *.flv *.webm *.m4v);;All Files (*)"
)
if file_path:
self.play_video(file_path)
def play_video(self, file_path: str, template_data: Dict[str, Any] = None):
"""Play video file with optional overlay data"""
try: try:
url = QUrl.fromLocalFile(str(Path(file_path).absolute())) with QMutexLocker(self.mutex):
content = QMediaContent(url) url = QUrl.fromLocalFile(str(Path(file_path).absolute()))
self.media_player.setMedia(content) self.media_player.setSource(url)
if self.settings.auto_play: # Update overlay with video info
self.media_player.play() overlay_data = template_data or {}
overlay_data.update({
logger.info(f"Playing video: {file_path}") 'title': f'Playing: {Path(file_path).name}',
'subtitle': 'MbetterClient PyQt6 Player'
})
self.video_widget.get_overlay_view().update_overlay_data(overlay_data)
if self.settings.auto_play:
self.media_player.play()
# Start background metadata extraction
worker = VideoProcessingWorker(
"metadata_extraction",
{"file_path": file_path},
self.on_metadata_extracted
)
self.thread_pool.start(worker)
logger.info(f"Playing video: {file_path}")
self.video_loaded.emit(file_path)
except Exception as e: except Exception as e:
logger.error(f"Failed to play video: {e}") logger.error(f"Failed to play video: {e}")
def on_metadata_extracted(self, metadata: Dict[str, Any]):
"""Handle extracted metadata"""
if 'error' not in metadata:
self.video_widget.get_overlay_view().update_video_info(metadata)
logger.debug(f"Video metadata: {metadata}")
def toggle_play_pause(self): def toggle_play_pause(self):
"""Toggle play/pause""" """Toggle play/pause (thread-safe)"""
if self.media_player.state() == QMediaPlayer.PlayingState: with QMutexLocker(self.mutex):
self.media_player.pause() if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
else: self.media_player.pause()
self.media_player.play() else:
self.media_player.play()
def stop_playback(self): def stop_playback(self):
"""Stop playback""" """Stop playback (thread-safe)"""
self.media_player.stop() with QMutexLocker(self.mutex):
self.media_player.stop()
def seek_to_position(self, percentage: int): def seek_to_position(self, percentage: int):
"""Seek to position (percentage)""" """Seek to position (percentage) (thread-safe)"""
duration = self.media_player.duration() with QMutexLocker(self.mutex):
if duration > 0: duration = self.media_player.duration()
position = int(duration * percentage / 100) if duration > 0:
self.media_player.setPosition(position) position = int(duration * percentage / 100)
self.media_player.setPosition(position)
def set_volume(self, volume: int):
"""Set volume (0-100) (thread-safe)"""
with QMutexLocker(self.mutex):
self.audio_output.setVolume(volume / 100.0)
def toggle_fullscreen(self):
"""Toggle fullscreen mode"""
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def toggle_stats(self):
"""Toggle stats panel"""
overlay_view = self.video_widget.get_overlay_view()
# Toggle stats via WebChannel
if overlay_view.overlay_channel:
current_stats = getattr(self, '_stats_visible', False)
overlay_view.overlay_channel.toggleStats(not current_stats)
self._stats_visible = not current_stats
def on_state_changed(self, state): def on_state_changed(self, state):
"""Handle state changes""" """Handle playback state changes (thread-safe)"""
is_playing = state == QMediaPlayer.PlayingState is_playing = state == QMediaPlayer.PlaybackState.PlayingState
self.controls.update_play_pause_button(is_playing) self.controls.update_play_pause_button(is_playing)
# Auto-hide controls when playing in fullscreen # Auto-hide controls when playing in fullscreen
if is_playing and self.settings.fullscreen: if is_playing and self.isFullScreen():
self.start_controls_timer() self.start_controls_timer()
def on_position_changed(self, position): def on_position_changed(self, position):
"""Handle position changes""" """Handle position changes (thread-safe)"""
duration = self.media_player.duration() duration = self.media_player.duration()
self.controls.update_position(position, duration) self.controls.update_position(position, duration)
# Update overlay engine with current position # Update overlay with position info
if self.overlay_engine: if duration > 0:
self.overlay_engine.update_playback_position(position, duration) self.video_widget.get_overlay_view().update_position(
position / 1000.0, # Convert to seconds
duration / 1000.0 # Convert to seconds
)
# Trigger overlay repaint on the overlay widget # Emit signal for other components
if hasattr(self.video_widget, 'overlay_widget'): self.position_changed.emit(position, duration)
self.video_widget.overlay_widget.update()
def on_duration_changed(self, duration): def on_duration_changed(self, duration):
"""Handle duration changes""" """Handle duration changes"""
...@@ -354,31 +677,56 @@ class PlayerWindow(QMainWindow): ...@@ -354,31 +677,56 @@ class PlayerWindow(QMainWindow):
def on_media_error(self, error): def on_media_error(self, error):
"""Handle media errors""" """Handle media errors"""
logger.error(f"Media player error: {error}") logger.error(f"Media player error: {error}")
# Show error in overlay
self.video_widget.get_overlay_view().update_overlay_data({
'title': 'Playback Error',
'subtitle': f'Error: {error.name if hasattr(error, "name") else str(error)}',
'ticker': 'Please check the video file and try again.'
})
def on_media_status_changed(self, status):
"""Handle media status changes"""
logger.debug(f"Media status changed: {status}")
if status == QMediaPlayer.MediaStatus.LoadedMedia:
# Media loaded successfully
self.video_widget.get_overlay_view().update_overlay_data({
'subtitle': 'Media loaded successfully'
})
def update_overlay_periodically(self):
"""Periodic overlay updates"""
# Update current time in overlay
current_time = time.strftime("%H:%M:%S")
self.video_widget.get_overlay_view().update_overlay_data({
'currentTime': current_time
})
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
"""Show controls on mouse movement""" """Show controls on mouse movement"""
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
self.show_controls() self.show_controls()
if self.settings.fullscreen and self.media_player.state() == QMediaPlayer.PlayingState: if self.isFullScreen() and self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self.start_controls_timer() self.start_controls_timer()
def keyPressEvent(self, event): def keyPressEvent(self, event):
"""Handle key presses""" """Handle key presses"""
if event.key() == Qt.Key_Space: if event.key() == Qt.Key.Key_Space:
self.toggle_play_pause() self.toggle_play_pause()
elif event.key() == Qt.Key_Escape: elif event.key() == Qt.Key.Key_Escape:
if self.isFullScreen(): if self.isFullScreen():
self.showNormal() self.showNormal()
else: else:
self.close() self.close()
elif event.key() == Qt.Key_F11: elif event.key() == Qt.Key.Key_F11:
if self.isFullScreen(): self.toggle_fullscreen()
self.showNormal() elif event.key() == Qt.Key.Key_M:
else: current_muted = self.audio_output.isMuted()
self.showFullScreen() self.audio_output.setMuted(not current_muted)
elif event.key() == Qt.Key_M: elif event.key() == Qt.Key.Key_S:
self.media_player.setMuted(not self.media_player.isMuted()) self.toggle_stats()
super().keyPressEvent(event) super().keyPressEvent(event)
...@@ -387,14 +735,14 @@ class PlayerWindow(QMainWindow): ...@@ -387,14 +735,14 @@ class PlayerWindow(QMainWindow):
if not self.controls_visible: if not self.controls_visible:
self.controls.show() self.controls.show()
self.controls_visible = True self.controls_visible = True
self.setCursor(Qt.ArrowCursor) self.setCursor(Qt.CursorShape.ArrowCursor)
def hide_controls(self): def hide_controls(self):
"""Hide controls""" """Hide controls"""
if self.settings.fullscreen and self.controls_visible: if self.isFullScreen() and self.controls_visible:
self.controls.hide() self.controls.hide()
self.controls_visible = False self.controls_visible = False
self.setCursor(Qt.BlankCursor) self.setCursor(Qt.CursorShape.BlankCursor)
def start_controls_timer(self): def start_controls_timer(self):
"""Start timer to hide controls""" """Start timer to hide controls"""
...@@ -402,117 +750,148 @@ class PlayerWindow(QMainWindow): ...@@ -402,117 +750,148 @@ class PlayerWindow(QMainWindow):
self.controls_timer.start(3000) # Hide after 3 seconds self.controls_timer.start(3000) # Hide after 3 seconds
def closeEvent(self, event): def closeEvent(self, event):
"""Handle window close""" """Handle window close (thread-safe)"""
self.media_player.stop() with QMutexLocker(self.mutex):
self.media_player.stop()
self.thread_pool.waitForDone(3000) # Wait up to 3 seconds for threads
event.accept() event.accept()
class QtVideoPlayer(ThreadedComponent): class QtVideoPlayer(ThreadedComponent):
"""Qt video player component""" """PyQt6 video player component with message bus integration (replaces PyQt5 version)"""
def __init__(self, message_bus: MessageBus, settings: QtConfig): def __init__(self, message_bus: MessageBus, settings: QtConfig):
super().__init__("qt_player", message_bus) super().__init__("qt_player", message_bus)
self.settings = settings self.settings = settings
self.app: Optional[QApplication] = None self.app: Optional[QApplication] = None
self.window: Optional[PlayerWindow] = None self.window: Optional[PlayerWindow] = None
self.overlay_engine: Optional[OverlayEngine] = None self.mutex = QMutex()
self.template_manager: Optional[TemplateManager] = None
# Register message queue # Register message queue
self.message_queue = self.message_bus.register_component(self.name) self.message_queue = self.message_bus.register_component(self.name)
logger.info("QtVideoPlayer initialized") logger.info("QtVideoPlayer (PyQt6) initialized")
def initialize(self) -> bool: def initialize(self) -> bool:
"""Initialize PyQt application and components""" """Initialize PyQt6 application and components"""
try: try:
# Create QApplication if it doesn't exist with QMutexLocker(self.mutex):
if not QApplication.instance(): # Linux-specific system configuration
self.app = QApplication(sys.argv) self._configure_linux_system()
self.app.setApplicationName("MbetterClient")
self.app.setQuitOnLastWindowClosed(True) # Create QApplication if it doesn't exist
else: if not QApplication.instance():
self.app = QApplication.instance() self.app = QApplication(sys.argv)
self.app.setApplicationName("MbetterClient PyQt6")
self.app.setApplicationVersion("2.0.0")
self.app.setQuitOnLastWindowClosed(True)
# Linux-specific application settings
self._configure_linux_app_settings()
else:
self.app = QApplication.instance()
# Create player window
self.window = PlayerWindow(self.settings)
# Connect window signals
self.window.position_changed.connect(self._on_position_changed)
self.window.video_loaded.connect(self._on_video_loaded)
# Subscribe to messages
self.message_bus.subscribe(self.name, MessageType.VIDEO_PLAY, self._handle_video_play)
self.message_bus.subscribe(self.name, MessageType.VIDEO_PAUSE, self._handle_video_pause)
self.message_bus.subscribe(self.name, MessageType.VIDEO_STOP, self._handle_video_stop)
self.message_bus.subscribe(self.name, MessageType.VIDEO_SEEK, self._handle_video_seek)
self.message_bus.subscribe(self.name, MessageType.VIDEO_VOLUME, self._handle_video_volume)
self.message_bus.subscribe(self.name, MessageType.VIDEO_FULLSCREEN, self._handle_video_fullscreen)
self.message_bus.subscribe(self.name, MessageType.TEMPLATE_CHANGE, self._handle_template_change)
self.message_bus.subscribe(self.name, MessageType.OVERLAY_UPDATE, self._handle_overlay_update)
self.message_bus.subscribe(self.name, MessageType.STATUS_REQUEST, self._handle_status_request)
# Load default overlay
self._load_default_overlay()
logger.info("QtVideoPlayer (PyQt6) initialized successfully")
return True
except Exception as e:
logger.error(f"QtVideoPlayer initialization failed: {e}")
return False
def _configure_linux_system(self):
"""Configure Linux-specific system settings"""
import platform
import os
if platform.system() != 'Linux':
return
# Initialize overlay engine try:
self.overlay_engine = OverlayEngine() # Set environment variables for better Linux compatibility
linux_env_vars = {
'QT_QPA_PLATFORM': 'xcb',
'QT_AUTO_SCREEN_SCALE_FACTOR': '1',
'QT_SCALE_FACTOR': '1',
'QT_LOGGING_RULES': 'qt.qpa.xcb.info=false;qt.qpa.xcb.xcberror.warning=false',
'QTWEBENGINE_CHROMIUM_FLAGS': '--no-sandbox --disable-gpu-sandbox',
'QTWEBENGINE_DISABLE_SANDBOX': '1'
}
# Initialize template manager for key, value in linux_env_vars.items():
self.template_manager = TemplateManager() if key not in os.environ:
os.environ[key] = value
logger.debug(f"Set Linux env var: {key}={value}")
# Create player window # Handle headless/server environments
self.window = PlayerWindow(self.settings, self.overlay_engine) if not os.environ.get('DISPLAY'):
logger.warning("No DISPLAY environment variable - may be running headless")
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
except Exception as e:
logger.warning(f"Linux system configuration warning: {e}")
def _configure_linux_app_settings(self):
"""Configure Linux-specific QApplication settings"""
import platform
if platform.system() != 'Linux' or not self.app:
return
# Load default template try:
self._load_default_template() # Set application attributes for better Linux compatibility
self.app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
self.app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
# Subscribe to messages # Handle X11 specific settings
self.message_bus.subscribe(self.name, MessageType.VIDEO_PLAY, self._handle_video_play) if hasattr(Qt.ApplicationAttribute, 'AA_X11InitThreads'):
self.message_bus.subscribe(self.name, MessageType.VIDEO_PAUSE, self._handle_video_pause) self.app.setAttribute(Qt.ApplicationAttribute.AA_X11InitThreads, True)
self.message_bus.subscribe(self.name, MessageType.VIDEO_STOP, self._handle_video_stop)
self.message_bus.subscribe(self.name, MessageType.VIDEO_SEEK, self._handle_video_seek)
self.message_bus.subscribe(self.name, MessageType.TEMPLATE_CHANGE, self._handle_template_change)
self.message_bus.subscribe(self.name, MessageType.OVERLAY_UPDATE, self._handle_overlay_update)
logger.info("QtVideoPlayer initialized successfully") logger.debug("Applied Linux-specific application settings")
return True
except Exception as e: except Exception as e:
logger.error(f"QtVideoPlayer initialization failed: {e}") logger.warning(f"Linux application configuration warning: {e}")
return False
def _load_default_template(self): def _load_default_overlay(self):
"""Load default template for display""" """Load default overlay display"""
try: try:
logger.info("Starting default template loading...") default_data = {
'title': 'MbetterClient PyQt6 Player',
if not self.template_manager: 'subtitle': 'Ready for Content',
logger.error("Template manager not initialized") 'ticker': 'Welcome to MbetterClient • Professional Video Overlay System • Real-time Updates • Hardware Accelerated Playback',
return 'showStats': False
}
if not self.overlay_engine:
logger.error("Overlay engine not initialized")
return
logger.info("Template manager and overlay engine are initialized")
# Load simple template by default
template_config = self.template_manager.get_template('simple_template')
logger.info(f"Template config retrieved: {template_config is not None}")
if template_config: if self.window:
logger.info(f"Template config keys: {list(template_config.keys())}") overlay_view = self.window.video_widget.get_overlay_view()
logger.info(f"Template elements count: {len(template_config.get('elements', []))}") overlay_view.update_overlay_data(default_data)
logger.info("Default overlay loaded successfully")
overlay_data = {
'simple_text': {'text': 'MbetterClient - Ready to Play'},
'timestamp': {'text': 'Waiting for video...'}
}
logger.info(f"Overlay data: {overlay_data}")
self.overlay_engine.load_template(template_config, overlay_data)
# Verify elements were loaded
elements_loaded = list(self.overlay_engine.renderer.elements.keys())
logger.info(f"Overlay elements loaded: {elements_loaded}")
if elements_loaded:
logger.info("Default template loaded successfully")
else:
logger.warning("Default template loaded but no elements created")
else:
logger.error("Default template not found in template manager")
available_templates = self.template_manager.get_template_names()
logger.info(f"Available templates: {available_templates}")
except Exception as e: except Exception as e:
logger.error(f"Failed to load default template: {e}") logger.error(f"Failed to load default overlay: {e}")
import traceback
logger.error(f"Template loading traceback: {traceback.format_exc()}")
def run(self): def run(self):
"""Main run loop""" """Main run loop with message bus integration"""
try: try:
logger.info("QtVideoPlayer thread started") logger.info("QtVideoPlayer thread started")
...@@ -520,7 +899,7 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -520,7 +899,7 @@ class QtVideoPlayer(ThreadedComponent):
ready_message = MessageBuilder.system_status( ready_message = MessageBuilder.system_status(
sender=self.name, sender=self.name,
status="ready", status="ready",
details={"fullscreen": self.settings.fullscreen} details={"fullscreen": self.settings.fullscreen, "version": "PyQt6-2.0.0"}
) )
self.message_bus.publish(ready_message) self.message_bus.publish(ready_message)
...@@ -531,23 +910,16 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -531,23 +910,16 @@ class QtVideoPlayer(ThreadedComponent):
if self.app: if self.app:
self.app.processEvents() self.app.processEvents()
# Process messages # Process messages from message bus
message = self.message_bus.get_message(self.name, timeout=0.1) message = self.message_bus.get_message(self.name, timeout=0.1)
if message: if message:
self._process_message(message) self._process_message(message)
# Send periodic progress updates if playing # Send periodic progress updates if playing
if (self.window and self.window.media_player.state() == QMediaPlayer.PlayingState): if (self.window and
self.window.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState):
self._send_progress_update() self._send_progress_update()
# Force overlay updates to keep animations smooth
if self.window and self.window.video_widget:
if self.overlay_engine and len(self.overlay_engine.renderer.elements) > 0:
logger.debug("Main loop: Triggering video widget overlay update")
else:
logger.debug("Main loop: No overlay elements available")
# The video widget now handles its own repaints via timer
# Update heartbeat # Update heartbeat
self.heartbeat() self.heartbeat()
...@@ -567,12 +939,13 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -567,12 +939,13 @@ class QtVideoPlayer(ThreadedComponent):
try: try:
logger.info("Shutting down QtVideoPlayer...") logger.info("Shutting down QtVideoPlayer...")
if self.window: with QMutexLocker(self.mutex):
self.window.close() if self.window:
self.window = None self.window.close()
self.window = None
if self.app:
self.app.quit() if self.app:
self.app.quit()
except Exception as e: except Exception as e:
logger.error(f"QtVideoPlayer shutdown error: {e}") logger.error(f"QtVideoPlayer shutdown error: {e}")
...@@ -580,33 +953,76 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -580,33 +953,76 @@ class QtVideoPlayer(ThreadedComponent):
def _process_message(self, message: Message): def _process_message(self, message: Message):
"""Process received message""" """Process received message"""
try: try:
# Messages are handled by subscribed handlers # Messages are handled by subscribed handlers automatically
# This is just for additional processing if needed
pass pass
except Exception as e: except Exception as e:
logger.error(f"Failed to process message: {e}") logger.error(f"Failed to process message: {e}")
def _on_position_changed(self, position: int, duration: int):
"""Handle position changes from player window"""
try:
# Send progress update via message bus
if duration > 0:
percentage = (position / duration) * 100
progress_message = MessageBuilder.video_progress(
sender=self.name,
position=position / 1000.0, # Convert to seconds
duration=duration / 1000.0, # Convert to seconds
percentage=percentage
)
self.message_bus.publish(progress_message, broadcast=True)
except Exception as e:
logger.error(f"Failed to send progress update: {e}")
def _on_video_loaded(self, file_path: str):
"""Handle video loaded event"""
try:
logger.info(f"Video loaded: {file_path}")
# Send video loaded status message
loaded_message = MessageBuilder.system_status(
sender=self.name,
status="video_loaded",
details={"file_path": file_path}
)
self.message_bus.publish(loaded_message, broadcast=True)
except Exception as e:
logger.error(f"Failed to handle video loaded event: {e}")
def _send_progress_update(self):
"""Send video progress update"""
try:
if self.window and self.window.media_player.duration() > 0:
position = self.window.media_player.position()
duration = self.window.media_player.duration()
percentage = (position / duration) * 100 if duration > 0 else 0
progress_message = MessageBuilder.video_progress(
sender=self.name,
position=position / 1000.0, # Convert to seconds
duration=duration / 1000.0, # Convert to seconds
percentage=percentage
)
self.message_bus.publish(progress_message, broadcast=True)
except Exception as e:
logger.error(f"Failed to send progress update: {e}")
# Message handlers for various video control commands
def _handle_video_play(self, message: Message): def _handle_video_play(self, message: Message):
"""Handle video play message""" """Handle video play message"""
try: try:
file_path = message.data.get("file_path") file_path = message.data.get("file_path")
template = message.data.get("template", "news_template") template_data = message.data.get("overlay_data", {})
overlay_data = message.data.get("overlay_data", {})
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} with template: {template}") logger.info(f"Playing video: {file_path}")
# Load template
if self.template_manager:
template_config = self.template_manager.get_template(template)
if template_config:
self.overlay_engine.load_template(template_config, overlay_data)
# Play video
if self.window: if self.window:
self.window.play_video(file_path) self.window.play_video(file_path, template_data)
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}")
...@@ -639,17 +1055,36 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -639,17 +1055,36 @@ class QtVideoPlayer(ThreadedComponent):
except Exception as e: except Exception as e:
logger.error(f"Failed to handle video seek: {e}") logger.error(f"Failed to handle video seek: {e}")
def _handle_video_volume(self, message: Message):
"""Handle video volume message"""
try:
volume = message.data.get("volume", 100)
if self.window:
self.window.set_volume(volume)
except Exception as e:
logger.error(f"Failed to handle video volume: {e}")
def _handle_video_fullscreen(self, message: Message):
"""Handle video fullscreen message"""
try:
fullscreen = message.data.get("fullscreen", True)
if self.window:
if fullscreen:
self.window.showFullScreen()
else:
self.window.showNormal()
except Exception as e:
logger.error(f"Failed to handle video fullscreen: {e}")
def _handle_template_change(self, message: Message): def _handle_template_change(self, message: Message):
"""Handle template change message""" """Handle template change message"""
try: try:
template_name = message.data.get("template_name")
template_data = message.data.get("template_data", {}) template_data = message.data.get("template_data", {})
if self.template_manager and template_name: if self.window and template_data:
template_config = self.template_manager.get_template(template_name) overlay_view = self.window.video_widget.get_overlay_view()
if template_config and self.overlay_engine: overlay_view.update_overlay_data(template_data)
self.overlay_engine.load_template(template_config, 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}")
...@@ -658,27 +1093,40 @@ class QtVideoPlayer(ThreadedComponent): ...@@ -658,27 +1093,40 @@ class QtVideoPlayer(ThreadedComponent):
try: try:
overlay_data = message.data.get("overlay_data", {}) overlay_data = message.data.get("overlay_data", {})
if self.overlay_engine: if self.window and overlay_data:
self.overlay_engine.update_overlay_data(overlay_data) overlay_view = self.window.video_widget.get_overlay_view()
overlay_view.update_overlay_data(overlay_data)
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}")
def _send_progress_update(self): def _handle_status_request(self, message: Message):
"""Send video progress update""" """Handle status request message"""
try: try:
if self.window and self.window.media_player.duration() > 0: if self.window:
position = self.window.media_player.position() is_playing = (self.window.media_player.playbackState() ==
duration = self.window.media_player.duration() QMediaPlayer.PlaybackState.PlayingState)
percentage = (position / duration) * 100 if duration > 0 else 0 position = self.window.media_player.position() / 1000.0 # seconds
duration = self.window.media_player.duration() / 1000.0 # seconds
volume = self.window.audio_output.volume() * 100
muted = self.window.audio_output.isMuted()
fullscreen = self.window.isFullScreen()
progress_message = MessageBuilder.video_progress( status_response = MessageBuilder.system_status(
sender=self.name, sender=self.name,
position=position / 1000.0, # Convert to seconds status="status_response",
duration=duration / 1000.0, # Convert to seconds details={
percentage=percentage "player_status": "playing" if is_playing else "paused",
"position": position,
"duration": duration,
"volume": volume,
"muted": muted,
"fullscreen": fullscreen
}
) )
self.message_bus.publish(progress_message, broadcast=True) # Send response back to requester
status_response.recipient = message.sender
self.message_bus.publish(status_response)
except Exception as e: except Exception as e:
logger.error(f"Failed to send progress update: {e}") logger.error(f"Failed to handle status request: {e}")
\ No newline at end of file \ No newline at end of file
"""
PyQt6 Multi-threaded Video Player with QWebEngineView Overlay System
"""
import sys
import time
import logging
import json
from pathlib import Path
from typing import Optional, Dict, Any
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QSlider, QFrame, QStackedWidget
)
from PyQt6.QtCore import (
Qt, QTimer, QThread, pyqtSignal, QUrl, QRect, QPropertyAnimation,
QEasingCurve, QSequentialAnimationGroup, QObject, QMutex, QMutexLocker,
QThreadPool, QRunnable, pyqtSlot
)
from PyQt6.QtGui import (
QFont, QPainter, QPen, QBrush, QColor, QPixmap, QMovie,
QLinearGradient, QFontMetrics, QAction
)
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
from ..core.thread_manager import ThreadedComponent
from ..core.message_bus import MessageBus, Message, MessageType, MessageBuilder
from ..config.settings import QtConfig
logger = logging.getLogger(__name__)
class OverlayWebChannel(QObject):
"""QObject for WebChannel communication with overlay HTML/JS"""
# Signals to send data to JavaScript
dataUpdated = pyqtSignal(dict)
positionChanged = pyqtSignal(float, float) # position, duration in seconds
videoInfoChanged = pyqtSignal(dict)
def __init__(self):
super().__init__()
self.mutex = QMutex()
self.overlay_data = {}
logger.info("OverlayWebChannel initialized")
@pyqtSlot(str)
def updateTitle(self, title: str):
"""Update main title from JavaScript"""
with QMutexLocker(self.mutex):
self.overlay_data['title'] = title
self.dataUpdated.emit({'title': title})
logger.debug(f"Title updated: {title}")
@pyqtSlot(str)
def updateSubtitle(self, subtitle: str):
"""Update subtitle from JavaScript"""
with QMutexLocker(self.mutex):
self.overlay_data['subtitle'] = subtitle
self.dataUpdated.emit({'subtitle': subtitle})
logger.debug(f"Subtitle updated: {subtitle}")
@pyqtSlot(bool)
def toggleStats(self, show: bool):
"""Toggle stats panel visibility"""
with QMutexLocker(self.mutex):
self.overlay_data['showStats'] = show
self.dataUpdated.emit({'showStats': show})
logger.debug(f"Stats panel toggled: {show}")
def send_data_update(self, data: Dict[str, Any]):
"""Send data update to JavaScript (thread-safe)"""
with QMutexLocker(self.mutex):
self.overlay_data.update(data)
self.dataUpdated.emit(data)
def send_position_update(self, position: float, duration: float):
"""Send playback position update to JavaScript (thread-safe)"""
self.positionChanged.emit(position, duration)
def send_video_info(self, info: Dict[str, Any]):
"""Send video information to JavaScript (thread-safe)"""
self.videoInfoChanged.emit(info)
class VideoProcessingWorker(QRunnable):
"""Background worker for video processing tasks"""
def __init__(self, task_type: str, data: Dict[str, Any], callback=None):
super().__init__()
self.task_type = task_type
self.data = data
self.callback = callback
self.setAutoDelete(True)
def run(self):
"""Execute the video processing task"""
try:
logger.debug(f"Processing video task: {self.task_type}")
if self.task_type == "metadata_extraction":
result = self._extract_metadata()
elif self.task_type == "thumbnail_generation":
result = self._generate_thumbnail()
elif self.task_type == "overlay_rendering":
result = self._render_overlay()
else:
result = {"error": f"Unknown task type: {self.task_type}"}
if self.callback:
self.callback(result)
except Exception as e:
logger.error(f"Video processing worker error: {e}")
if self.callback:
self.callback({"error": str(e)})
def _extract_metadata(self) -> Dict[str, Any]:
"""Extract video metadata"""
# Placeholder for metadata extraction
return {
"resolution": "1920x1080",
"bitrate": "5.2 Mbps",
"codec": "H.264",
"fps": "30.0"
}
def _generate_thumbnail(self) -> Dict[str, Any]:
"""Generate video thumbnail"""
# Placeholder for thumbnail generation
return {"thumbnail_path": "/tmp/thumbnail.jpg"}
def _render_overlay(self) -> Dict[str, Any]:
"""Render overlay elements"""
# Placeholder for overlay rendering
return {"rendered": True}
class OverlayWebView(QWebEngineView):
"""Custom QWebEngineView for video overlays with transparent background"""
def __init__(self, parent=None):
super().__init__(parent)
self.web_channel = None
self.overlay_channel = None
self.setup_web_view()
logger.info("OverlayWebView initialized")
def setup_web_view(self):
"""Setup web view for transparent overlays"""
# Enable transparency
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
# Configure page settings
page = self.page()
page.setBackgroundColor(QColor(0, 0, 0, 0)) # Transparent background
# Setup WebChannel
self.web_channel = QWebChannel()
self.overlay_channel = OverlayWebChannel()
self.web_channel.registerObject("overlay", self.overlay_channel)
page.setWebChannel(self.web_channel)
# Load overlay HTML
overlay_path = Path(__file__).parent / "overlay.html"
if overlay_path.exists():
self.load(QUrl.fromLocalFile(str(overlay_path)))
logger.info(f"Loaded overlay HTML: {overlay_path}")
else:
logger.error(f"Overlay HTML not found: {overlay_path}")
def get_overlay_channel(self) -> OverlayWebChannel:
"""Get the overlay communication channel"""
return self.overlay_channel
def update_overlay_data(self, data: Dict[str, Any]):
"""Update overlay with new data"""
if self.overlay_channel:
self.overlay_channel.send_data_update(data)
def update_position(self, position: float, duration: float):
"""Update playback position"""
if self.overlay_channel:
self.overlay_channel.send_position_update(position, duration)
def update_video_info(self, info: Dict[str, Any]):
"""Update video information"""
if self.overlay_channel:
self.overlay_channel.send_video_info(info)
class VideoWidget(QWidget):
"""Composite video widget with QWebEngineView overlay"""
def __init__(self, parent=None):
super().__init__(parent)
self.setup_ui()
logger.info("VideoWidget initialized")
def setup_ui(self):
"""Setup video widget with overlay"""
self.setStyleSheet("background-color: black;")
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create stacked widget for layering
self.stacked_widget = QStackedWidget()
# Create video widget for actual video playback
self.video_widget = QVideoWidget()
self.video_widget.setStyleSheet("background-color: black;")
self.stacked_widget.addWidget(self.video_widget)
# Create overlay web view
self.overlay_view = OverlayWebView()
self.stacked_widget.addWidget(self.overlay_view)
layout.addWidget(self.stacked_widget)
# Ensure overlay is on top by setting the current widget
self.stacked_widget.setCurrentWidget(self.overlay_view)
def get_video_widget(self) -> QVideoWidget:
"""Get the video widget for media player"""
return self.video_widget
def get_overlay_view(self) -> OverlayWebView:
"""Get the overlay web view"""
return self.overlay_view
def resizeEvent(self, event):
"""Handle resize events"""
super().resizeEvent(event)
# Both widgets should automatically resize with the stacked widget
class PlayerControlsWidget(QWidget):
"""Enhanced video player controls with thread-safe operations"""
play_pause_clicked = pyqtSignal()
stop_clicked = pyqtSignal()
seek_requested = pyqtSignal(int)
volume_changed = pyqtSignal(int)
def __init__(self, parent=None):
super().__init__(parent)
self.mutex = QMutex()
self.setup_ui()
def setup_ui(self):
"""Setup enhanced control UI"""
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 5, 10, 5)
# Play/Pause button
self.play_pause_btn = QPushButton("⏸️")
self.play_pause_btn.setFixedSize(40, 30)
self.play_pause_btn.clicked.connect(self.play_pause_clicked.emit)
# Stop button
self.stop_btn = QPushButton("⏹️")
self.stop_btn.setFixedSize(40, 30)
self.stop_btn.clicked.connect(self.stop_clicked.emit)
# Position slider
self.position_slider = QSlider(Qt.Orientation.Horizontal)
self.position_slider.setMinimum(0)
self.position_slider.setMaximum(100)
self.position_slider.sliderPressed.connect(self._on_slider_pressed)
self.position_slider.sliderReleased.connect(self._on_slider_released)
# Volume slider
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
self.volume_slider.setMinimum(0)
self.volume_slider.setMaximum(100)
self.volume_slider.setValue(100)
self.volume_slider.setMaximumWidth(100)
self.volume_slider.valueChanged.connect(self.volume_changed.emit)
# Time labels
self.current_time_label = QLabel("00:00")
self.duration_label = QLabel("00:00")
# Volume label
volume_label = QLabel("🔊")
# Layout
layout.addWidget(self.play_pause_btn)
layout.addWidget(self.stop_btn)
layout.addWidget(self.current_time_label)
layout.addWidget(self.position_slider, 1) # Stretch
layout.addWidget(self.duration_label)
layout.addWidget(volume_label)
layout.addWidget(self.volume_slider)
# Enhanced styling
self.setStyleSheet("""
QWidget {
background-color: rgba(0, 0, 0, 200);
color: white;
}
QPushButton {
border: 1px solid #555;
border-radius: 5px;
background-color: #333;
font-weight: bold;
}
QPushButton:hover {
background-color: #555;
}
QPushButton:pressed {
background-color: #777;
}
QSlider::groove:horizontal {
border: 1px solid #555;
height: 6px;
background: #222;
border-radius: 3px;
}
QSlider::handle:horizontal {
background: #fff;
border: 2px solid #555;
width: 14px;
margin: -5px 0;
border-radius: 7px;
}
QSlider::handle:horizontal:hover {
background: #ddd;
}
""")
self.slider_pressed = False
def _on_slider_pressed(self):
with QMutexLocker(self.mutex):
self.slider_pressed = True
def _on_slider_released(self):
with QMutexLocker(self.mutex):
self.slider_pressed = False
self.seek_requested.emit(self.position_slider.value())
def update_position(self, position: int, duration: int):
"""Update position display (thread-safe)"""
with QMutexLocker(self.mutex):
if not self.slider_pressed and duration > 0:
self.position_slider.setValue(int(position * 100 / duration))
self.current_time_label.setText(self._format_time(position))
self.duration_label.setText(self._format_time(duration))
def update_play_pause_button(self, is_playing: bool):
"""Update play/pause button state (thread-safe)"""
with QMutexLocker(self.mutex):
self.play_pause_btn.setText("⏸️" if is_playing else "▶️")
def _format_time(self, ms: int) -> str:
"""Format time in milliseconds to MM:SS"""
seconds = ms // 1000
minutes = seconds // 60
seconds = seconds % 60
return f"{minutes:02d}:{seconds:02d}"
class PlayerWindow(QMainWindow):
"""Enhanced main player window with thread-safe operations"""
# Signals for thread communication
position_changed = pyqtSignal(int, int)
video_loaded = pyqtSignal(str)
def __init__(self, settings: QtConfig):
super().__init__()
self.settings = settings
self.mutex = QMutex()
self.thread_pool = QThreadPool()
self.thread_pool.setMaxThreadCount(4)
self.setup_ui()
self.setup_media_player()
self.setup_timers()
logger.info("PlayerWindow initialized")
def setup_ui(self):
"""Setup enhanced window UI"""
self.setWindowTitle("MbetterClient - PyQt6 Video Player")
self.setStyleSheet("background-color: black;")
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Layout
layout = QVBoxLayout(central_widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Video widget with overlay
self.video_widget = VideoWidget()
layout.addWidget(self.video_widget, 1) # Stretch
# Controls
self.controls = PlayerControlsWidget()
self.controls.play_pause_clicked.connect(self.toggle_play_pause)
self.controls.stop_clicked.connect(self.stop_playback)
self.controls.seek_requested.connect(self.seek_to_position)
self.controls.volume_changed.connect(self.set_volume)
layout.addWidget(self.controls)
# Window settings
if self.settings.fullscreen:
self.showFullScreen()
else:
self.resize(self.settings.window_width, self.settings.window_height)
self.show()
if self.settings.always_on_top:
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Setup menu
self.setup_menu()
def setup_menu(self):
"""Setup application menu"""
menubar = self.menuBar()
# File menu
file_menu = menubar.addMenu('File')
open_action = QAction('Open Video', self)
open_action.triggered.connect(self.open_file_dialog)
file_menu.addAction(open_action)
# View menu
view_menu = menubar.addMenu('View')
fullscreen_action = QAction('Toggle Fullscreen', self)
fullscreen_action.setShortcut('F11')
fullscreen_action.triggered.connect(self.toggle_fullscreen)
view_menu.addAction(fullscreen_action)
stats_action = QAction('Toggle Stats', self)
stats_action.setShortcut('S')
stats_action.triggered.connect(self.toggle_stats)
view_menu.addAction(stats_action)
def setup_media_player(self):
"""Setup PyQt6 media player with audio output"""
self.media_player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.media_player.setAudioOutput(self.audio_output)
self.media_player.setVideoOutput(self.video_widget.get_video_widget())
# Connect signals
self.media_player.playbackStateChanged.connect(self.on_state_changed)
self.media_player.positionChanged.connect(self.on_position_changed)
self.media_player.durationChanged.connect(self.on_duration_changed)
self.media_player.errorOccurred.connect(self.on_media_error)
self.media_player.mediaStatusChanged.connect(self.on_media_status_changed)
# Set volume
self.audio_output.setVolume(self.settings.volume)
self.audio_output.setMuted(self.settings.mute)
def setup_timers(self):
"""Setup various timers"""
# Auto-hide controls timer
self.controls_timer = QTimer()
self.controls_timer.timeout.connect(self.hide_controls)
self.controls_timer.setSingleShot(True)
# Overlay update timer
self.overlay_timer = QTimer()
self.overlay_timer.timeout.connect(self.update_overlay_periodically)
self.overlay_timer.start(1000) # Update every second
# Mouse tracking for showing controls
self.setMouseTracking(True)
self.controls_visible = True
def open_file_dialog(self):
"""Open file dialog to select video"""
from PyQt6.QtWidgets import QFileDialog
file_path, _ = QFileDialog.getOpenFileName(
self,
"Open Video File",
"",
"Video Files (*.mp4 *.avi *.mkv *.mov *.wmv *.flv *.webm *.m4v);;All Files (*)"
)
if file_path:
self.play_video(file_path)
def play_video(self, file_path: str, template_data: Dict[str, Any] = None):
"""Play video file with optional overlay data"""
try:
with QMutexLocker(self.mutex):
url = QUrl.fromLocalFile(str(Path(file_path).absolute()))
self.media_player.setSource(url)
# Update overlay with video info
overlay_data = template_data or {}
overlay_data.update({
'title': f'Playing: {Path(file_path).name}',
'subtitle': 'MbetterClient PyQt6 Player'
})
self.video_widget.get_overlay_view().update_overlay_data(overlay_data)
if self.settings.auto_play:
self.media_player.play()
# Start background metadata extraction
worker = VideoProcessingWorker(
"metadata_extraction",
{"file_path": file_path},
self.on_metadata_extracted
)
self.thread_pool.start(worker)
logger.info(f"Playing video: {file_path}")
self.video_loaded.emit(file_path)
except Exception as e:
logger.error(f"Failed to play video: {e}")
def on_metadata_extracted(self, metadata: Dict[str, Any]):
"""Handle extracted metadata"""
if 'error' not in metadata:
self.video_widget.get_overlay_view().update_video_info(metadata)
logger.debug(f"Video metadata: {metadata}")
def toggle_play_pause(self):
"""Toggle play/pause (thread-safe)"""
with QMutexLocker(self.mutex):
if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self.media_player.pause()
else:
self.media_player.play()
def stop_playback(self):
"""Stop playback (thread-safe)"""
with QMutexLocker(self.mutex):
self.media_player.stop()
def seek_to_position(self, percentage: int):
"""Seek to position (percentage) (thread-safe)"""
with QMutexLocker(self.mutex):
duration = self.media_player.duration()
if duration > 0:
position = int(duration * percentage / 100)
self.media_player.setPosition(position)
def set_volume(self, volume: int):
"""Set volume (0-100) (thread-safe)"""
with QMutexLocker(self.mutex):
self.audio_output.setVolume(volume / 100.0)
def toggle_fullscreen(self):
"""Toggle fullscreen mode"""
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
def toggle_stats(self):
"""Toggle stats panel"""
overlay_view = self.video_widget.get_overlay_view()
# Toggle stats via WebChannel
if overlay_view.overlay_channel:
current_stats = getattr(self, '_stats_visible', False)
overlay_view.overlay_channel.toggleStats(not current_stats)
self._stats_visible = not current_stats
def on_state_changed(self, state):
"""Handle playback state changes (thread-safe)"""
is_playing = state == QMediaPlayer.PlaybackState.PlayingState
self.controls.update_play_pause_button(is_playing)
# Auto-hide controls when playing in fullscreen
if is_playing and self.isFullScreen():
self.start_controls_timer()
def on_position_changed(self, position):
"""Handle position changes (thread-safe)"""
duration = self.media_player.duration()
self.controls.update_position(position, duration)
# Update overlay with position info
if duration > 0:
self.video_widget.get_overlay_view().update_position(
position / 1000.0, # Convert to seconds
duration / 1000.0 # Convert to seconds
)
# Emit signal for other components
self.position_changed.emit(position, duration)
def on_duration_changed(self, duration):
"""Handle duration changes"""
self.controls.update_position(self.media_player.position(), duration)
def on_media_error(self, error):
"""Handle media errors"""
logger.error(f"Media player error: {error}")
# Show error in overlay
self.video_widget.get_overlay_view().update_overlay_data({
'title': 'Playback Error',
'subtitle': f'Error: {error.name if hasattr(error, "name") else str(error)}',
'ticker': 'Please check the video file and try again.'
})
def on_media_status_changed(self, status):
"""Handle media status changes"""
logger.debug(f"Media status changed: {status}")
if status == QMediaPlayer.MediaStatus.LoadedMedia:
# Media loaded successfully
self.video_widget.get_overlay_view().update_overlay_data({
'subtitle': 'Media loaded successfully'
})
def update_overlay_periodically(self):
"""Periodic overlay updates"""
# Update current time in overlay
current_time = time.strftime("%H:%M:%S")
self.video_widget.get_overlay_view().update_overlay_data({
'currentTime': current_time
})
def mouseMoveEvent(self, event):
"""Show controls on mouse movement"""
super().mouseMoveEvent(event)
self.show_controls()
if self.isFullScreen() and self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self.start_controls_timer()
def keyPressEvent(self, event):
"""Handle key presses"""
if event.key() == Qt.Key.Key_Space:
self.toggle_play_pause()
elif event.key() == Qt.Key.Key_Escape:
if self.isFullScreen():
self.showNormal()
else:
self.close()
elif event.key() == Qt.Key.Key_F11:
self.toggle_fullscreen()
elif event.key() == Qt.Key.Key_M:
current_muted = self.audio_output.isMuted()
self.audio_output.setMuted(not current_muted)
elif event.key() == Qt.Key.Key_S:
self.toggle_stats()
super().keyPressEvent(event)
def show_controls(self):
"""Show controls"""
if not self.controls_visible:
self.controls.show()
self.controls_visible = True
self.setCursor(Qt.CursorShape.ArrowCursor)
def hide_controls(self):
"""Hide controls"""
if self.isFullScreen() and self.controls_visible:
self.controls.hide()
self.controls_visible = False
self.setCursor(Qt.CursorShape.BlankCursor)
def start_controls_timer(self):
"""Start timer to hide controls"""
self.controls_timer.stop()
self.controls_timer.start(3000) # Hide after 3 seconds
def closeEvent(self, event):
"""Handle window close (thread-safe)"""
with QMutexLocker(self.mutex):
self.media_player.stop()
self.thread_pool.waitForDone(3000) # Wait up to 3 seconds for threads
event.accept()
class Qt6VideoPlayer(ThreadedComponent):
"""PyQt6 video player component with advanced features"""
def __init__(self, message_bus: MessageBus, settings: QtConfig):
super().__init__("qt6_player", message_bus)
self.settings = settings
self.app: Optional[QApplication] = None
self.window: Optional[PlayerWindow] = None
self.mutex = QMutex()
# Register message queue
self.message_queue = self.message_bus.register_component(self.name)
logger.info("Qt6VideoPlayer initialized")
def initialize(self) -> bool:
"""Initialize PyQt6 application and components"""
try:
with QMutexLocker(self.mutex):
# Create QApplication if it doesn't exist
if not QApplication.instance():
self.app = QApplication(sys.argv)
self.app.setApplicationName("MbetterClient PyQt6")
self.app.setApplicationVersion("2.0.0")
self.app.setQuitOnLastWindowClosed(True)
else:
self.app = QApplication.instance()
# Create player window
self.window = PlayerWindow(self.settings)
# Connect window signals
self.window.position_changed.connect(self._on_position_changed)
self.window.video_loaded.connect(self._on_video_loaded)
# Subscribe to messages
self.message_bus.subscribe(self.name, MessageType.VIDEO_PLAY, self._handle_video_play)
self.message_bus.subscribe(self.name, MessageType.VIDEO_PAUSE, self._handle_video_pause)
self.message_bus.subscribe(self.name, MessageType.VIDEO_STOP, self._handle_video_stop)
self.message_bus.subscribe(self.name, MessageType.VIDEO_SEEK, self._handle_video_seek)
self.message_bus.subscribe(self.name, MessageType.TEMPLATE_CHANGE, self._handle_template_change)
self.message_bus.subscribe(self.name, MessageType.OVERLAY_UPDATE, self._handle_overlay_update)
logger.info("Qt6VideoPlayer initialized successfully")
return True
except Exception as e:
logger.error(f"Qt6VideoPlayer initialization failed: {e}")
return False
def run(self):
"""Main run loop"""
try:
logger.info("Qt6VideoPlayer thread started")
# Send ready status
ready_message = MessageBuilder.system_status(
sender=self.name,
status="ready",
details={"fullscreen": self.settings.fullscreen, "version": "PyQt6-2.0.0"}
)
self.message_bus.publish(ready_message)
# Message processing loop
while self.running:
try:
# Process Qt events
if self.app:
self.app.processEvents()
# Process messages
message = self.message_bus.get_message(self.name, timeout=0.1)
if message:
self._process_message(message)
# Update heartbeat
self.heartbeat()
time.sleep(0.016) # ~60 FPS
except Exception as e:
logger.error(f"Qt6VideoPlayer run loop error: {e}")
time.sleep(0.1)
except Exception as e:
logger.error(f"Qt6VideoPlayer run failed: {e}")
finally:
logger.info("Qt6VideoPlayer thread ended")
def shutdown(self):
"""Shutdown video player"""
try:
logger.info("Shutting down Qt6VideoPlayer...")
with QMutexLocker(self.mutex):
if self.window:
self.window.close()
self.window = None
if self.app:
self.app.quit()
except Exception as e:
logger.error(f"Qt6VideoPlayer shutdown error: {e}")
def _process_message(self, message: Message):
"""Process received message"""
try:
# Messages are handled by subscribed handlers
pass
except Exception as e:
logger.error(f"Failed to process message: {e}")
def _on_position_changed(self, position: int, duration: int):
"""Handle position changes from player window"""
try:
# Send progress update
if duration > 0:
percentage = (position / duration) * 100
progress_message = MessageBuilder.video_progress(
sender=self.name,
position=position / 1000.0, # Convert to seconds
duration=duration / 1000.0, # Convert to seconds
percentage=percentage
)
self.message_bus.publish(progress_message, broadcast=True)
except Exception as e:
logger.error(f"Failed to send progress update: {e}")
def _on_video_loaded(self, file_path: str):
"""Handle video loaded event"""
try:
logger.info(f"Video loaded: {file_path}")
# Could send status update here
except Exception as e:
logger.error(f"Failed to handle video loaded event: {e}")
def _handle_video_play(self, message: Message):
"""Handle video play message"""
try:
file_path = message.data.get("file_path")
template_data = message.data.get("overlay_data", {})
if not file_path:
logger.error("No file path provided for video play")
return
logger.info(f"Playing video: {file_path}")
if self.window:
self.window.play_video(file_path, template_data)
except Exception as e:
logger.error(f"Failed to handle video play: {e}")
def _handle_video_pause(self, message: Message):
"""Handle video pause message"""
try:
if self.window:
self.window.media_player.pause()
except Exception as e:
logger.error(f"Failed to handle video pause: {e}")
def _handle_video_stop(self, message: Message):
"""Handle video stop message"""
try:
if self.window:
self.window.stop_playback()
except Exception as e:
logger.error(f"Failed to handle video stop: {e}")
def _handle_video_seek(self, message: Message):
"""Handle video seek message"""
try:
position = message.data.get("position", 0)
if self.window:
duration = self.window.media_player.duration()
if duration > 0:
percentage = int(position * 100 / duration)
self.window.seek_to_position(percentage)
except Exception as e:
logger.error(f"Failed to handle video seek: {e}")
def _handle_template_change(self, message: Message):
"""Handle template change message"""
try:
template_data = message.data.get("template_data", {})
if self.window and template_data:
overlay_view = self.window.video_widget.get_overlay_view()
overlay_view.update_overlay_data(template_data)
except Exception as e:
logger.error(f"Failed to handle template change: {e}")
def _handle_overlay_update(self, message: Message):
"""Handle overlay update message"""
try:
overlay_data = message.data.get("overlay_data", {})
if self.window and overlay_data:
overlay_view = self.window.video_widget.get_overlay_view()
overlay_view.update_overlay_data(overlay_data)
except Exception as e:
logger.error(f"Failed to handle overlay update: {e}")
\ No newline at end of file
...@@ -5,8 +5,9 @@ Flask-WTF>=1.1.0 ...@@ -5,8 +5,9 @@ Flask-WTF>=1.1.0
Flask-JWT-Extended>=4.4.0 Flask-JWT-Extended>=4.4.0
requests>=2.28.0 requests>=2.28.0
# GUI - PyQt5 # GUI - PyQt6 with WebEngine (Multimedia included in main package)
PyQt5>=5.15.0 PyQt6>=6.4.0
PyQt6-WebEngine>=6.4.0
# Database # Database
SQLAlchemy>=2.0.0 SQLAlchemy>=2.0.0
......
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