"""VideoPlayer — plays a local video file and delivers frames via frame_ready signal. The public interface mirrors CameraService so MainWindow can treat both interchangeably: both emit frame_ready(QVideoFrame). """ from __future__ import annotations import logging from pathlib import Path from PySide6.QtCore import QObject, QUrl, Signal, Slot from PySide6.QtMultimedia import ( QMediaPlayer, QVideoFrame, QVideoSink, ) logger = logging.getLogger(__name__) class VideoPlayer(QObject): """ Wraps QMediaPlayer + QVideoSink to replay a local video file. Signal flow (identical interface to CameraService): VideoPlayer.frame_ready(QVideoFrame) → FrameDispatcher Notes: - Playback is real-time (1×) — no seek/pause in this version. - At end-of-file: emits playback_stopped() and stops. - On any error: emits playback_error(str) then playback_stopped(). """ frame_ready = Signal(QVideoFrame) playback_started = Signal() playback_stopped = Signal() playback_error = Signal(str) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._player = QMediaPlayer(self) self._sink = QVideoSink(self) self._player.setVideoSink(self._sink) self._sink.videoFrameChanged.connect(self._on_frame) self._player.playbackStateChanged.connect(self._on_playback_state_changed) self._player.errorOccurred.connect(self._on_error) self._current_path: str | None = None # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def play(self, path: str) -> None: """Open and start playing a video file.""" self.stop() p = Path(path) if not p.exists(): msg = f"Video file not found: {path}" logger.error(msg) self.playback_error.emit(msg) return self._current_path = path url = QUrl.fromLocalFile(str(p.resolve())) self._player.setSource(url) self._player.play() logger.info("VideoPlayer: starting playback of '%s'", p.name) def stop(self) -> None: """Stop playback and clear source.""" if self._player.playbackState() != QMediaPlayer.PlaybackState.StoppedState: self._player.stop() self._player.setSource(QUrl()) self._current_path = None @property def is_playing(self) -> bool: return ( self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState ) @property def current_path(self) -> str | None: return self._current_path # ------------------------------------------------------------------ # Private slots # ------------------------------------------------------------------ @Slot(QVideoFrame) def _on_frame(self, frame: QVideoFrame) -> None: if frame.isValid(): self.frame_ready.emit(frame) @Slot(QMediaPlayer.PlaybackState) def _on_playback_state_changed(self, state: QMediaPlayer.PlaybackState) -> None: if state == QMediaPlayer.PlaybackState.PlayingState: logger.info("VideoPlayer: playing") self.playback_started.emit() elif state == QMediaPlayer.PlaybackState.StoppedState: logger.info("VideoPlayer: stopped") self.playback_stopped.emit() @Slot(QMediaPlayer.Error, str) def _on_error(self, error: QMediaPlayer.Error, error_string: str) -> None: if error == QMediaPlayer.Error.NoError: return msg = f"VideoPlayer error: {error_string}" logger.error(msg) self.playback_error.emit(msg) self.playback_stopped.emit()