9c4749e49b
Also stops one benign libmpv message being logged as an error: `after creating texture: OpenGL error INVALID_ENUM` comes from the GL driver, arrives several times per playback start and per fullscreen toggle, and playback continues regardless. Hundreds of lines a session buried the errors that matter -- it is a debug line now. Verified end to end on the real display with an actual video playing: ten tab switches and four fullscreen toggles while frames were rendering, position advancing throughout, the render context intact, no crash, and an empty error log afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1145 lines
45 KiB
Python
1145 lines
45 KiB
Python
"""
|
|
Embedded mpv player
|
|
===================
|
|
|
|
MpvRenderWidget renders libmpv into a QOpenGLWidget via the mpv render API
|
|
(the wid=winId() embedding path is broken on native Wayland, the render API
|
|
works on X11/Wayland/Windows/macOS alike).
|
|
|
|
PlayerPanel wraps the render widget with transport controls and resolves
|
|
YouTube URLs through mpv's ytdl_hook, pointed at the app-managed yt-dlp
|
|
binary, so stream URL freshness, DASH muxing, subtitles and PO-token/nsig
|
|
handling are all handled by the same yt-dlp the downloader uses.
|
|
|
|
Threading rule: libmpv fires property observers and the render-update
|
|
callback on its own threads. Nothing in those callbacks may touch Qt
|
|
widgets - they only emit queued Qt signals.
|
|
"""
|
|
|
|
import sys
|
|
import threading
|
|
import webbrowser
|
|
from typing import Any, Dict, Optional
|
|
|
|
from PySide6.QtCore import QCoreApplication, Qt, QTimer, Signal, Slot
|
|
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
|
from PySide6.QtGui import QOpenGLContext
|
|
from PySide6.QtWidgets import (
|
|
QApplication,
|
|
QComboBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QPushButton,
|
|
QSizePolicy,
|
|
QSlider,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from . import ytsage_icons as icons
|
|
from . import ytsage_theme as theme
|
|
from .ytsage_player_input import SEEK_MEDIUM, build_context_menu, install_player_shortcuts
|
|
from ..core.ytsage_mpv import probe_player
|
|
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
|
from ..utils.ytsage_config_manager import ConfigManager
|
|
from ..utils.ytsage_localization import _
|
|
from ..utils.ytsage_logger import logger
|
|
|
|
QUALITY_CHOICES = [
|
|
("player.quality_auto", None),
|
|
("2160p", 2160),
|
|
("1440p", 1440),
|
|
("1080p", 1080),
|
|
("720p", 720),
|
|
("480p", 480),
|
|
("360p", 360),
|
|
]
|
|
|
|
SPEED_CHOICES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
|
|
|
|
|
def _ytdl_format_for(height: Optional[int]) -> str:
|
|
if height is None:
|
|
return "bestvideo+bestaudio/best"
|
|
return f"bestvideo[height<=?{height}]+bestaudio/best[height<=?{height}]"
|
|
|
|
|
|
def _build_ytdl_raw_options() -> str:
|
|
"""
|
|
Mirror the app's cookie/proxy config into ytdl_hook raw options.
|
|
|
|
mpv splits this option on commas, so any comma inside a value would end
|
|
the option and start a bogus one -- silently, and a Windows cookie path or
|
|
a proxy URL with credentials can easily contain one. mpv's own escape for
|
|
that is a backslash, so values are escaped rather than trusted.
|
|
"""
|
|
|
|
def esc(value) -> str:
|
|
return str(value).replace("\\", "\\\\").replace(",", "\\,")
|
|
|
|
opts = []
|
|
if ConfigManager.get("cookie_active"):
|
|
if ConfigManager.get("cookie_source") == "file":
|
|
path = ConfigManager.get("cookie_file_path")
|
|
if path:
|
|
opts.append(f"cookies={esc(path)}")
|
|
else:
|
|
browser = ConfigManager.get("cookie_browser")
|
|
profile = ConfigManager.get("cookie_browser_profile")
|
|
if browser:
|
|
value = f"{browser}:{profile}" if profile else browser
|
|
opts.append(f"cookies-from-browser={esc(value)}")
|
|
proxy = ConfigManager.get("proxy_url")
|
|
if proxy:
|
|
opts.append(f"proxy={esc(proxy)}")
|
|
# The downloader honours geo_proxy_url; the player was ignoring it, so a
|
|
# geo-restricted video downloaded but would not play.
|
|
geo_proxy = ConfigManager.get("geo_proxy_url")
|
|
if geo_proxy:
|
|
opts.append(f"geo-verification-proxy={esc(geo_proxy)}")
|
|
return ",".join(opts)
|
|
|
|
|
|
def _default_hwdec() -> str:
|
|
"""
|
|
A platform-appropriate hwdec list.
|
|
|
|
Left unset, mpv's decoder choice varies with how libmpv was built, which
|
|
makes playback problems unreproducible between machines. Naming the
|
|
candidates makes it deterministic, and every list ends in `no` so a
|
|
machine whose GPU interop is broken falls back to software decoding
|
|
rather than showing a black frame.
|
|
|
|
Note this does *not* silence `Cannot load libcuda.so.1`: that comes from
|
|
the GL/driver stack below mpv and appears with `hwdec=no` too. It is
|
|
harmless stderr noise, not a decoder mpv chose.
|
|
"""
|
|
if sys.platform.startswith("win"):
|
|
return "d3d11va,dxva2-copy,no"
|
|
if sys.platform == "darwin":
|
|
return "videotoolbox,no"
|
|
return "vaapi,vaapi-copy,no"
|
|
|
|
|
|
class MpvRenderWidget(QOpenGLWidget):
|
|
"""
|
|
QOpenGLWidget hosting a libmpv render context.
|
|
|
|
Lifetime rule, and the reason this class is shaped the way it is
|
|
-------------------------------------------------------------------
|
|
The **mpv handle** is independent of OpenGL and owns playback state, so it
|
|
lives as long as the widget. The **render context** belongs to exactly one
|
|
QOpenGLContext and must not outlive it.
|
|
|
|
Qt destroys and recreates a widget's QOpenGLContext whenever the widget
|
|
moves to another top-level window, and calls initializeGL() again on the
|
|
new one. libmpv permits only one render context per handle, so the second
|
|
creation fails -- and the previous code responded by assigning
|
|
`self._render_ctx = None`, dropping the last Python reference to a context
|
|
libmpv was still holding a function pointer into. python-mpv's
|
|
MpvRenderContext has no __del__ and free() does not unregister the
|
|
callback, so the ctypes trampoline was collected under libmpv's feet and
|
|
the next frame notification jumped into freed memory. That was the
|
|
segfault.
|
|
|
|
Two invariants prevent it:
|
|
|
|
1. initializeGL() tears down any existing render context first, so a
|
|
second call is a clean recreation rather than an error.
|
|
2. Teardown clears update_cb, calls free() with the GL context current,
|
|
and only then drops the reference -- and it is wired to
|
|
QOpenGLContext.aboutToBeDestroyed, so it runs *before* the GL context
|
|
goes away rather than never.
|
|
|
|
Threading: libmpv fires property observers and the render-update callback
|
|
on its own threads. Nothing in those callbacks touches Qt widgets; they
|
|
only emit queued signals.
|
|
"""
|
|
|
|
# Emitted from mpv threads; connected queued to GUI-thread slots
|
|
mpvPositionChanged = Signal(float)
|
|
mpvDurationChanged = Signal(float)
|
|
mpvPausedChanged = Signal(bool)
|
|
mpvEndReached = Signal(str) # end-file reason
|
|
mpvError = Signal(str)
|
|
mpvMuteChanged = Signal(bool)
|
|
mpvVolumeChanged = Signal(float)
|
|
mpvBufferingChanged = Signal(bool)
|
|
_renderUpdateRequested = Signal()
|
|
|
|
# Mouse gestures, surfaced for PlayerPanel to act on. Kept here rather
|
|
# than on the panel so the control bar below the video is unaffected.
|
|
clicked = Signal()
|
|
doubleClicked = Signal()
|
|
wheelScrolled = Signal(int, bool) # notches, ctrl_held
|
|
mouseMovedOverVideo = Signal()
|
|
|
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
self.setMinimumHeight(240)
|
|
# Without this the widget cannot hold focus, which is why the panel's
|
|
# key handler never ran.
|
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
self.setMouseTracking(True)
|
|
self._wheel_remainder = 0
|
|
# A single click must not act until a double-click can be ruled out,
|
|
# or double-click-to-fullscreen also pauses on the way.
|
|
self._click_timer = QTimer(self)
|
|
self._click_timer.setSingleShot(True)
|
|
self._click_timer.timeout.connect(self.clicked)
|
|
self._mpv = None
|
|
self._render_ctx = None
|
|
self._gl_ctx = None
|
|
self._proc_addr_fn = None
|
|
self._shutting_down = False
|
|
# Gates the mpv-thread render callback. Cleared before free() so a
|
|
# notification arriving mid-teardown becomes a no-op.
|
|
self._render_alive = threading.Event()
|
|
self._renderUpdateRequested.connect(self._request_repaint, Qt.ConnectionType.QueuedConnection)
|
|
self.frameSwapped.connect(self._on_frame_swapped)
|
|
self._create_mpv()
|
|
|
|
# closeEvent is not the only way this process ends. Without this, an
|
|
# exception during teardown or any exit that bypasses the main
|
|
# window's closeEvent leaves libmpv running against a dying
|
|
# interpreter.
|
|
app = QCoreApplication.instance()
|
|
if app is not None:
|
|
app.aboutToQuit.connect(self.shutdown)
|
|
|
|
# ------------------------------------------------------------------ mpv
|
|
|
|
def _create_mpv(self) -> None:
|
|
import mpv
|
|
|
|
hwdec = ConfigManager.get("player.hwdec") or "auto"
|
|
kwargs: Dict[str, Any] = {
|
|
"vo": "libmpv",
|
|
"ytdl": True,
|
|
"keep_open": "yes",
|
|
"idle": "yes",
|
|
"osc": False,
|
|
"input_default_bindings": False,
|
|
"hwdec": _default_hwdec() if hwdec == "auto" else str(hwdec),
|
|
# Applied here rather than only through the slider: the slider
|
|
# sets its restored value before its valueChanged is connected,
|
|
# so mpv never saw it and playback always started at 100.
|
|
"volume": max(0, min(100, int(ConfigManager.get("player.volume") or 100))),
|
|
}
|
|
ytdlp_path = get_yt_dlp_path()
|
|
if str(ytdlp_path) != "yt-dlp":
|
|
kwargs["script_opts"] = f"ytdl_hook-ytdl_path={ytdlp_path}"
|
|
|
|
self._mpv = mpv.MPV(log_handler=self._on_mpv_log, **kwargs)
|
|
self._mpv["ytdl-format"] = _ytdl_format_for(ConfigManager.get("player.default_quality"))
|
|
raw_opts = _build_ytdl_raw_options()
|
|
if raw_opts:
|
|
self._mpv["ytdl-raw-options"] = raw_opts
|
|
|
|
self._mpv.observe_property("time-pos", self._on_time_pos)
|
|
self._mpv.observe_property("duration", self._on_duration)
|
|
self._mpv.observe_property("pause", self._on_pause)
|
|
# Observed rather than assumed, so the button follows changes made
|
|
# by a key, the menu or mpv itself.
|
|
self._mpv.observe_property("mute", self._on_mute)
|
|
self._mpv.observe_property("volume", self._on_volume)
|
|
# Buffering was entirely invisible: a stall showed a frozen frame for
|
|
# 25 seconds before the retry, with nothing on screen.
|
|
self._mpv.observe_property("paused-for-cache", self._on_cache_pause)
|
|
|
|
@self._mpv.event_callback("end-file")
|
|
def _on_end_file(event): # mpv thread
|
|
try:
|
|
reason = str(getattr(event.data, "reason", ""))
|
|
except Exception:
|
|
reason = ""
|
|
self.mpvEndReached.emit(reason)
|
|
|
|
# mpv-thread callbacks: signals only, no widget access
|
|
def _on_time_pos(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvPositionChanged.emit(float(value))
|
|
|
|
def _on_duration(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvDurationChanged.emit(float(value))
|
|
|
|
def _on_pause(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvPausedChanged.emit(bool(value))
|
|
|
|
def _on_mute(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvMuteChanged.emit(bool(value))
|
|
|
|
def _on_volume(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvVolumeChanged.emit(float(value))
|
|
|
|
def _on_cache_pause(self, _name, value) -> None:
|
|
if value is not None:
|
|
self.mpvBufferingChanged.emit(bool(value))
|
|
|
|
#: libmpv reports these at error level, but they are noise from the GL
|
|
#: driver rather than a failure: playback continues and frames keep
|
|
#: arriving. They were filling the error log with hundreds of lines per
|
|
#: session, which buries the errors that do matter.
|
|
_BENIGN_MPV_ERRORS = (
|
|
"after creating texture: OpenGL error INVALID_ENUM",
|
|
)
|
|
|
|
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
|
message = text.strip()
|
|
if level in ("error", "fatal"):
|
|
if level == "error" and any(noise in message for noise in self._BENIGN_MPV_ERRORS):
|
|
logger.debug(f"mpv [{prefix}] {message}")
|
|
return
|
|
logger.error(f"mpv [{prefix}] {message}")
|
|
if "ytdl" in prefix or level == "fatal":
|
|
self.mpvError.emit(message)
|
|
else:
|
|
logger.debug(f"mpv [{prefix}] {message}")
|
|
|
|
# --------------------------------------------------------------- OpenGL
|
|
|
|
def _on_render_update(self) -> None:
|
|
"""
|
|
libmpv render thread. Must not touch Qt widgets.
|
|
|
|
The alive gate matters: between clearing it and free() returning,
|
|
libmpv may still invoke this, and by then the widget may be on its way
|
|
out.
|
|
"""
|
|
if self._render_alive.is_set():
|
|
self._renderUpdateRequested.emit()
|
|
|
|
@Slot()
|
|
def _request_repaint(self) -> None:
|
|
if self._render_ctx is not None and not self._shutting_down:
|
|
self.update()
|
|
|
|
@Slot()
|
|
def _on_frame_swapped(self) -> None:
|
|
"""Let libmpv time its display-sync against real buffer swaps."""
|
|
ctx = self._render_ctx
|
|
if ctx is None or self._shutting_down:
|
|
return
|
|
try:
|
|
ctx.report_swap()
|
|
except Exception as e:
|
|
logger.debug(f"mpv report_swap failed: {e}")
|
|
|
|
def initializeGL(self) -> None:
|
|
from mpv import MpvGlGetProcAddressFn, MpvRenderContext
|
|
|
|
# A previous QOpenGLContext may still own a render context: Qt calls
|
|
# initializeGL again after a context loss, and libmpv allows exactly
|
|
# one per handle. Tearing down first is what turns "There is already a
|
|
# mpv_render_context set" into an ordinary recreation.
|
|
self._teardown_render_context()
|
|
|
|
if self._mpv is None or self._shutting_down:
|
|
return
|
|
|
|
def get_proc_address(_ctx, name):
|
|
glctx = QOpenGLContext.currentContext()
|
|
if glctx is None:
|
|
return 0
|
|
address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8"))
|
|
return int(address) if address else 0
|
|
|
|
# Held on the instance: libmpv keeps the raw pointer and some drivers
|
|
# resolve symbols lazily, well after creation returns.
|
|
proc_addr_fn = MpvGlGetProcAddressFn(get_proc_address)
|
|
try:
|
|
render_ctx = MpvRenderContext(
|
|
self._mpv,
|
|
"opengl",
|
|
opengl_init_params={"get_proc_address": proc_addr_fn},
|
|
)
|
|
except Exception as e:
|
|
# Leave the widget black but keep the app alive (e.g. software GL
|
|
# contexts that libmpv rejects)
|
|
logger.error(f"Failed to create mpv render context: {e}")
|
|
self.mpvError.emit(f"Video output initialization failed: {e}")
|
|
return
|
|
|
|
self._proc_addr_fn = proc_addr_fn
|
|
self._render_ctx = render_ctx
|
|
self._render_alive.set()
|
|
render_ctx.update_cb = self._on_render_update # mpv thread
|
|
|
|
gl_ctx = QOpenGLContext.currentContext()
|
|
self._gl_ctx = gl_ctx
|
|
if gl_ctx is not None:
|
|
# The one hook the old code was missing. Without it the render
|
|
# context outlives the GL context that owns it -- or is never
|
|
# freed at all.
|
|
gl_ctx.aboutToBeDestroyed.connect(
|
|
self._on_gl_context_about_to_be_destroyed,
|
|
Qt.ConnectionType.DirectConnection,
|
|
)
|
|
|
|
@Slot()
|
|
def _on_gl_context_about_to_be_destroyed(self) -> None:
|
|
"""GUI thread. free() requires its GL context to be current."""
|
|
try:
|
|
self.makeCurrent()
|
|
self._teardown_render_context()
|
|
finally:
|
|
try:
|
|
self.doneCurrent()
|
|
except Exception:
|
|
pass
|
|
|
|
def _teardown_render_context(self) -> None:
|
|
"""
|
|
Release the render context, in the only order that is safe.
|
|
|
|
The local reference is load-bearing: it keeps the object -- and the
|
|
ctypes trampoline libmpv points at -- alive until free() has returned.
|
|
Dropping it earlier is precisely the use-after-free this class exists
|
|
to avoid.
|
|
"""
|
|
render_ctx, self._render_ctx = self._render_ctx, None
|
|
gl_ctx, self._gl_ctx = self._gl_ctx, None
|
|
self._render_alive.clear()
|
|
|
|
if gl_ctx is not None:
|
|
try:
|
|
gl_ctx.aboutToBeDestroyed.disconnect(self._on_gl_context_about_to_be_destroyed)
|
|
except (RuntimeError, TypeError):
|
|
# Already disconnected, or the C++ object is gone.
|
|
pass
|
|
|
|
if render_ctx is None:
|
|
self._proc_addr_fn = None
|
|
return
|
|
|
|
try:
|
|
# Note this does *not* unregister: python-mpv installs a no-op
|
|
# wrapper instead. It only guarantees that a callback arriving
|
|
# before free() does nothing. free() is what actually unsets the
|
|
# callback and blocks until in-flight invocations return.
|
|
render_ctx.update_cb = None
|
|
except Exception as e:
|
|
logger.debug(f"Clearing mpv update callback failed: {e}")
|
|
|
|
try:
|
|
render_ctx.free()
|
|
except Exception as e:
|
|
logger.debug(f"Error freeing mpv render context: {e}")
|
|
finally:
|
|
self._proc_addr_fn = None
|
|
|
|
def paintGL(self) -> None:
|
|
# Alias first: self._render_ctx can be cleared by a teardown between
|
|
# the check and the render.
|
|
render_ctx = self._render_ctx
|
|
if render_ctx is None or self._shutting_down:
|
|
return
|
|
ratio = self.devicePixelRatioF()
|
|
w = int(self.width() * ratio)
|
|
h = int(self.height() * ratio)
|
|
if w <= 0 or h <= 0:
|
|
return
|
|
try:
|
|
render_ctx.render(
|
|
flip_y=True,
|
|
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"mpv render failed: {e}")
|
|
|
|
def resizeGL(self, w: int, h: int) -> None:
|
|
# The FBO size travels with every render call, so nothing needs
|
|
# resizing here -- but a resize while paused must still repaint, or
|
|
# the last frame stays stretched to the old geometry.
|
|
self.update()
|
|
|
|
def shutdown(self) -> None:
|
|
"""Idempotent: reachable from closeEvent, aboutToQuit and WatchPage."""
|
|
if self._shutting_down:
|
|
return
|
|
self._shutting_down = True
|
|
|
|
# Render context before the handle: terminating mpv while a render
|
|
# context is registered is undefined.
|
|
try:
|
|
if self.context() is not None:
|
|
self.makeCurrent()
|
|
try:
|
|
self._teardown_render_context()
|
|
finally:
|
|
self.doneCurrent()
|
|
else:
|
|
self._teardown_render_context()
|
|
except Exception as e:
|
|
logger.debug(f"Error tearing down mpv render context: {e}")
|
|
|
|
mpv_inst, self._mpv = self._mpv, None
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst.terminate()
|
|
except Exception as e:
|
|
logger.debug(f"Error terminating mpv: {e}")
|
|
|
|
# ----------------------------------------------------------------- mouse
|
|
|
|
def mousePressEvent(self, event) -> None:
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
|
self._click_timer.start(QApplication.doubleClickInterval())
|
|
super().mousePressEvent(event)
|
|
|
|
def mouseDoubleClickEvent(self, event) -> None:
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self._click_timer.stop() # cancel the pending single-click
|
|
self.doubleClicked.emit()
|
|
super().mouseDoubleClickEvent(event)
|
|
|
|
def wheelEvent(self, event) -> None:
|
|
# Accumulate: a high-resolution trackpad sends far less than one
|
|
# notch (120 units) per event, and dropping those makes it inert.
|
|
self._wheel_remainder += event.angleDelta().y()
|
|
notches, self._wheel_remainder = divmod(abs(self._wheel_remainder), 120)
|
|
if notches:
|
|
sign = 1 if event.angleDelta().y() > 0 else -1
|
|
ctrl = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)
|
|
self.wheelScrolled.emit(sign * int(notches), ctrl)
|
|
event.accept()
|
|
|
|
def mouseMoveEvent(self, event) -> None:
|
|
self.mouseMovedOverVideo.emit()
|
|
super().mouseMoveEvent(event)
|
|
|
|
# ------------------------------------------------------------- controls
|
|
|
|
@property
|
|
def mpv(self):
|
|
return self._mpv
|
|
|
|
|
|
class PlayerPanel(QWidget):
|
|
"""Video area + transport controls. Public API: play/enqueue-agnostic."""
|
|
|
|
positionChanged = Signal(float)
|
|
durationChanged = Signal(float)
|
|
playbackEnded = Signal(str) # end-file reason ("eof", "error", ...)
|
|
playerError = Signal(str)
|
|
nowPlayingChanged = Signal(dict) # entry dict of the current item
|
|
# The panel stays queue-agnostic: it asks, WatchPage decides what is next.
|
|
nextRequested = Signal()
|
|
previousRequested = Signal()
|
|
|
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
self._current_entry: Dict[str, Any] = {}
|
|
self._duration: float = 0.0
|
|
self._slider_down = False
|
|
self._fullscreen_controller = None
|
|
self._muted = False
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(4)
|
|
|
|
self.video = MpvRenderWidget(self)
|
|
layout.addWidget(self.video, stretch=1)
|
|
|
|
# A child of the video widget, not a sibling in the layout: it must
|
|
# float over the frame without changing the geometry underneath.
|
|
self.buffering_label = QLabel(_("player.buffering"), self.video)
|
|
self.buffering_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.buffering_label.setStyleSheet(
|
|
f"background-color: rgba(0,0,0,170); color: {theme.TEXT};"
|
|
"padding: 8px 16px; border-radius: 6px; font-size: 13px;"
|
|
)
|
|
self.buffering_label.hide()
|
|
|
|
self.title_label = QLabel("")
|
|
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
|
|
self.title_label.setWordWrap(True)
|
|
layout.addWidget(self.title_label)
|
|
|
|
# A container, not a bare layout: fullscreen needs something it can
|
|
# hide, and hiding a layout is not a thing.
|
|
self.controls_bar = QWidget(self)
|
|
controls = QHBoxLayout(self.controls_bar)
|
|
controls.setSpacing(8)
|
|
controls.setContentsMargins(6, 0, 6, 4)
|
|
|
|
self.prev_btn = QPushButton()
|
|
self.prev_btn.setIcon(icons.icon("skip-back", theme.ICON_ON_ACCENT))
|
|
self.prev_btn.setFixedWidth(36)
|
|
self.prev_btn.setToolTip(_("player.previous"))
|
|
self.prev_btn.clicked.connect(self.request_previous)
|
|
controls.addWidget(self.prev_btn)
|
|
|
|
self.play_btn = QPushButton()
|
|
self.play_btn.setIcon(icons.icon("play", theme.ICON_ON_ACCENT))
|
|
self.play_btn.setFixedWidth(36)
|
|
self.play_btn.setToolTip(_("player.play_pause"))
|
|
self.play_btn.clicked.connect(self.toggle_pause)
|
|
controls.addWidget(self.play_btn)
|
|
|
|
self.next_btn = QPushButton()
|
|
self.next_btn.setIcon(icons.icon("skip-forward", theme.ICON_ON_ACCENT))
|
|
self.next_btn.setFixedWidth(36)
|
|
self.next_btn.setToolTip(_("player.next"))
|
|
self.next_btn.clicked.connect(self.request_next)
|
|
controls.addWidget(self.next_btn)
|
|
|
|
self.time_label = QLabel("0:00 / 0:00")
|
|
controls.addWidget(self.time_label)
|
|
|
|
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.seek_slider.setRange(0, 1000)
|
|
self.seek_slider.setToolTip(_("player.seek_tooltip"))
|
|
self.seek_slider.sliderPressed.connect(self._on_slider_pressed)
|
|
self.seek_slider.sliderReleased.connect(self._on_slider_released)
|
|
controls.addWidget(self.seek_slider, stretch=1)
|
|
|
|
self.quality_combo = QComboBox()
|
|
for label, height in QUALITY_CHOICES:
|
|
self.quality_combo.addItem(_(label) if label.startswith("player.") else label, height)
|
|
default_q = ConfigManager.get("player.default_quality")
|
|
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
|
|
self.quality_combo.setCurrentIndex(idx)
|
|
self.quality_combo.setToolTip(_("player.quality_tooltip"))
|
|
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
|
|
controls.addWidget(self.quality_combo)
|
|
|
|
self.speed_combo = QComboBox()
|
|
for s in SPEED_CHOICES:
|
|
self.speed_combo.addItem(f"{s:g}x", s)
|
|
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0))
|
|
self.speed_combo.setToolTip(_("player.speed_tooltip"))
|
|
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
|
|
controls.addWidget(self.speed_combo)
|
|
|
|
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.volume_slider.setRange(0, 100)
|
|
self.volume_slider.setFixedWidth(90)
|
|
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
|
|
self.volume_slider.setToolTip(_("player.volume_tooltip"))
|
|
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
|
|
|
self.mute_btn = QPushButton()
|
|
self.mute_btn.setIcon(icons.icon("volume-high", theme.ICON_ON_ACCENT))
|
|
self.mute_btn.setFixedWidth(36)
|
|
self.mute_btn.setToolTip(_("player.act_mute"))
|
|
self.mute_btn.clicked.connect(self.toggle_mute)
|
|
controls.addWidget(self.mute_btn)
|
|
controls.addWidget(self.volume_slider)
|
|
|
|
self.subs_btn = QPushButton(_("player.subtitles"))
|
|
self.subs_btn.setCheckable(True)
|
|
self.subs_btn.setToolTip(_("player.subtitles_tooltip"))
|
|
self.subs_btn.toggled.connect(self._on_subs_toggled)
|
|
controls.addWidget(self.subs_btn)
|
|
|
|
self.fullscreen_btn = QPushButton()
|
|
self.fullscreen_btn.setIcon(icons.icon("maximize", theme.ICON_ON_ACCENT))
|
|
self.fullscreen_btn.setFixedWidth(36)
|
|
self.fullscreen_btn.setToolTip(_("player.fullscreen"))
|
|
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
|
controls.addWidget(self.fullscreen_btn)
|
|
|
|
layout.addWidget(self.controls_bar)
|
|
|
|
# mpv-thread signals arrive queued on the GUI thread
|
|
self.video.mpvPositionChanged.connect(self._on_position, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvDurationChanged.connect(self._on_duration, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvPausedChanged.connect(self._on_paused_changed, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvEndReached.connect(self._on_end_reached, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvError.connect(self.playerError, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvMuteChanged.connect(self._on_mute_changed, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvVolumeChanged.connect(self._on_mpv_volume, Qt.ConnectionType.QueuedConnection)
|
|
self.video.mpvBufferingChanged.connect(self._on_buffering, Qt.ConnectionType.QueuedConnection)
|
|
|
|
self._volume_apply_timer = QTimer(self)
|
|
self._volume_apply_timer.setSingleShot(True)
|
|
self._volume_apply_timer.setInterval(400)
|
|
self._volume_apply_timer.timeout.connect(self._persist_volume)
|
|
|
|
# YouTube's CDN intermittently serves stalled/poisoned streams to
|
|
# non-browser clients; a reload re-resolves the URLs and usually
|
|
# lands on a healthy node. Retry automatically on startup stall.
|
|
self._stall_timer = QTimer(self)
|
|
self._stall_timer.setSingleShot(True)
|
|
self._stall_timer.setInterval(25000)
|
|
self._stall_timer.timeout.connect(self._on_startup_stall)
|
|
self._stall_retries = 0
|
|
self._playback_started = False
|
|
|
|
# --- input ------------------------------------------------------
|
|
# The panel must be able to hold focus for its shortcuts to fire...
|
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
# ...and the control bar must not take it, or clicking play would
|
|
# park focus on a button that then swallows Space. This is why the
|
|
# old keyPressEvent never ran.
|
|
for widget in (
|
|
self.prev_btn, self.play_btn, self.next_btn, self.mute_btn,
|
|
self.subs_btn, self.fullscreen_btn, self.seek_slider,
|
|
self.volume_slider, self.quality_combo, self.speed_combo,
|
|
):
|
|
widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
|
|
# Kept on the instance: an unreferenced QShortcut is collected and
|
|
# silently stops working.
|
|
self._shortcuts = install_player_shortcuts(self)
|
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
|
|
|
|
self.video.clicked.connect(self.toggle_pause)
|
|
self.video.doubleClicked.connect(self.toggle_fullscreen)
|
|
self.video.wheelScrolled.connect(self._on_wheel)
|
|
self.video.mouseMovedOverVideo.connect(self._on_mouse_activity)
|
|
|
|
# Controls hide themselves in fullscreen only; doing it in a window
|
|
# would make the layout jump under the pointer.
|
|
self._idle_timer = QTimer(self)
|
|
self._idle_timer.setSingleShot(True)
|
|
self._idle_timer.setInterval(2500)
|
|
self._idle_timer.timeout.connect(self._hide_idle_controls)
|
|
|
|
# ------------------------------------------------------------ public API
|
|
|
|
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
|
|
"""Play a video. entry needs at least {"url": ...}; extra keys
|
|
(id/title/channel/duration/thumbnail) travel to nowPlayingChanged."""
|
|
url = entry.get("url") or entry.get("webpage_url")
|
|
if not url:
|
|
self.playerError.emit("No playable URL in entry")
|
|
return
|
|
self._current_entry = dict(entry)
|
|
title = entry.get("title") or url
|
|
self.title_label.setText(title)
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
options = {}
|
|
if resume_pos and resume_pos > 0:
|
|
options["start"] = f"+{max(0.0, resume_pos - 5.0):.1f}"
|
|
try:
|
|
mpv_inst.loadfile(url, **options)
|
|
mpv_inst["pause"] = False
|
|
except Exception as e:
|
|
logger.exception(f"mpv loadfile failed: {e}")
|
|
self.playerError.emit(str(e))
|
|
return
|
|
self._playback_started = False
|
|
self._stall_timer.start()
|
|
self.nowPlayingChanged.emit(self._current_entry)
|
|
|
|
def _on_startup_stall(self) -> None:
|
|
if self._playback_started or not self._current_entry:
|
|
return
|
|
if self._stall_retries < 2:
|
|
self._stall_retries += 1
|
|
logger.warning(f"Stream stalled before starting; retrying ({self._stall_retries}/2)")
|
|
entry = self._current_entry
|
|
self._current_entry = {}
|
|
self.play(entry)
|
|
else:
|
|
self._stall_retries = 0
|
|
self.playerError.emit(_("player.stream_stalled"))
|
|
|
|
def stop(self) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst.command("stop")
|
|
except Exception:
|
|
pass
|
|
|
|
def toggle_pause(self) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["pause"] = not mpv_inst["pause"]
|
|
except Exception:
|
|
pass
|
|
|
|
# ------------------------------------------------------- bound actions
|
|
# Every method below is reachable from a key, the context menu, or both;
|
|
# see ytsage_player_input.ACTIONS. They are all no-ops when nothing is
|
|
# loaded rather than raising, because a key can be pressed at any time.
|
|
|
|
def seek_relative(self, seconds: float) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
try:
|
|
mpv_inst.seek(seconds, reference="relative")
|
|
except Exception as e:
|
|
logger.debug(f"Relative seek failed: {e}")
|
|
|
|
def seek_absolute(self, seconds: float) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
try:
|
|
mpv_inst.seek(max(0.0, seconds), reference="absolute")
|
|
except Exception as e:
|
|
logger.debug(f"Absolute seek failed: {e}")
|
|
|
|
def seek_percent(self, fraction: float) -> None:
|
|
if self._duration > 0:
|
|
self.seek_absolute(self._duration * max(0.0, min(1.0, fraction)))
|
|
|
|
def seek_to_end(self) -> None:
|
|
if self._duration > 0:
|
|
# Not exactly the end: mpv would treat that as EOF and advance.
|
|
self.seek_absolute(max(0.0, self._duration - 3.0))
|
|
|
|
def frame_step(self, direction: int) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
try:
|
|
mpv_inst.command("frame-back-step" if direction < 0 else "frame-step")
|
|
except Exception as e:
|
|
logger.debug(f"Frame step failed: {e}")
|
|
|
|
def nudge_volume(self, delta: int) -> None:
|
|
self.volume_slider.setValue(max(0, min(100, self.volume_slider.value() + delta)))
|
|
|
|
def toggle_mute(self) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
try:
|
|
mpv_inst["mute"] = not bool(mpv_inst["mute"])
|
|
except Exception as e:
|
|
logger.debug(f"Mute toggle failed: {e}")
|
|
|
|
def nudge_speed(self, delta: float) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
try:
|
|
current = float(mpv_inst["speed"] or 1.0)
|
|
except Exception:
|
|
current = 1.0
|
|
self._apply_speed(max(0.25, min(4.0, round((current + delta) * 100) / 100)))
|
|
|
|
def reset_speed(self) -> None:
|
|
self._apply_speed(1.0)
|
|
|
|
def _apply_speed(self, speed: float) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["speed"] = speed
|
|
except Exception as e:
|
|
logger.debug(f"Speed change failed: {e}")
|
|
# Reflect it in the combo when it is one of the offered values,
|
|
# without re-triggering the handler.
|
|
index = self.speed_combo.findData(speed)
|
|
if index >= 0 and index != self.speed_combo.currentIndex():
|
|
self.speed_combo.blockSignals(True)
|
|
self.speed_combo.setCurrentIndex(index)
|
|
self.speed_combo.blockSignals(False)
|
|
|
|
def cycle_subtitles(self) -> None:
|
|
self.subs_btn.setChecked(not self.subs_btn.isChecked())
|
|
|
|
def request_next(self) -> None:
|
|
self.nextRequested.emit()
|
|
|
|
def request_previous(self) -> None:
|
|
self.previousRequested.emit()
|
|
|
|
def copy_video_url(self) -> None:
|
|
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
|
|
if url:
|
|
QApplication.clipboard().setText(str(url))
|
|
|
|
def open_in_browser(self) -> None:
|
|
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
|
|
if url:
|
|
webbrowser.open(str(url))
|
|
|
|
def reload_auth(self) -> None:
|
|
"""
|
|
Push changed cookie/proxy settings onto the live mpv handle.
|
|
|
|
These were read once, when the widget was built, so signing in only
|
|
reached playback after restarting the app. ytdl_hook consults the
|
|
option when it resolves a URL, so anything already playing keeps the
|
|
streams it resolved with -- it is reloaded at its current position.
|
|
"""
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
raw_opts = _build_ytdl_raw_options()
|
|
try:
|
|
mpv_inst["ytdl-raw-options"] = raw_opts
|
|
except Exception as e:
|
|
logger.debug(f"Could not update ytdl-raw-options: {e}")
|
|
return
|
|
logger.info("Player authentication settings reloaded.")
|
|
|
|
if self._current_entry and self._playback_started:
|
|
position = self.current_position()
|
|
entry = self._current_entry
|
|
self._current_entry = {}
|
|
self.play(entry, resume_pos=position)
|
|
|
|
def current_entry(self) -> Dict[str, Any]:
|
|
return dict(self._current_entry)
|
|
|
|
def current_position(self) -> float:
|
|
mpv_inst = self.video.mpv
|
|
try:
|
|
return float(mpv_inst["time-pos"] or 0.0) if mpv_inst else 0.0
|
|
except Exception:
|
|
return 0.0
|
|
|
|
def shutdown(self) -> None:
|
|
self.video.shutdown()
|
|
|
|
# ----------------------------------------------------------- slots (GUI)
|
|
|
|
@Slot(float)
|
|
def _on_position(self, pos: float) -> None:
|
|
if pos > 0 and not self._playback_started:
|
|
self._playback_started = True
|
|
self._stall_retries = 0
|
|
self._stall_timer.stop()
|
|
if not self._slider_down and self._duration > 0:
|
|
self.seek_slider.blockSignals(True)
|
|
self.seek_slider.setValue(int(pos / self._duration * 1000))
|
|
self.seek_slider.blockSignals(False)
|
|
self.time_label.setText(f"{_format_time(pos)} / {_format_time(self._duration)}")
|
|
self.positionChanged.emit(pos)
|
|
|
|
@Slot(float)
|
|
def _on_duration(self, duration: float) -> None:
|
|
self._duration = duration
|
|
self.durationChanged.emit(duration)
|
|
|
|
@Slot(bool)
|
|
def _on_paused_changed(self, paused: bool) -> None:
|
|
self.play_btn.setIcon(icons.icon("play" if paused else "pause", theme.ICON_ON_ACCENT))
|
|
self.play_btn.setToolTip(_("player.play") if paused else _("player.pause"))
|
|
|
|
@Slot(bool)
|
|
def _on_mute_changed(self, muted: bool) -> None:
|
|
self._muted = muted
|
|
self._refresh_volume_icon()
|
|
|
|
@Slot(float)
|
|
def _on_mpv_volume(self, volume: float) -> None:
|
|
# Reflect changes made by key or menu, without echoing back into mpv.
|
|
value = int(round(volume))
|
|
if value != self.volume_slider.value():
|
|
self.volume_slider.blockSignals(True)
|
|
self.volume_slider.setValue(value)
|
|
self.volume_slider.blockSignals(False)
|
|
self._volume_apply_timer.start()
|
|
self._refresh_volume_icon()
|
|
|
|
def _refresh_volume_icon(self) -> None:
|
|
if self._muted or self.volume_slider.value() == 0:
|
|
name = "volume-mute"
|
|
elif self.volume_slider.value() < 50:
|
|
name = "volume-low"
|
|
else:
|
|
name = "volume-high"
|
|
self.mute_btn.setIcon(icons.icon(name, theme.ICON_ON_ACCENT))
|
|
self.mute_btn.setToolTip(_("player.act_unmute") if self._muted else _("player.act_mute"))
|
|
|
|
@Slot(bool)
|
|
def _on_buffering(self, buffering: bool) -> None:
|
|
self.buffering_label.setVisible(buffering)
|
|
if buffering:
|
|
self.buffering_label.raise_()
|
|
|
|
@Slot(str)
|
|
def _on_end_reached(self, reason: str) -> None:
|
|
self.playbackEnded.emit(reason)
|
|
|
|
def _on_slider_pressed(self) -> None:
|
|
self._slider_down = True
|
|
|
|
def _on_slider_released(self) -> None:
|
|
self._slider_down = False
|
|
if self._duration > 0:
|
|
target = self.seek_slider.value() / 1000 * self._duration
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst.seek(target, reference="absolute")
|
|
except Exception as e:
|
|
logger.debug(f"Seek failed: {e}")
|
|
|
|
def _on_quality_changed(self, index: int) -> None:
|
|
height = self.quality_combo.itemData(index)
|
|
ConfigManager.set("player.default_quality", height)
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is None:
|
|
return
|
|
mpv_inst["ytdl-format"] = _ytdl_format_for(height)
|
|
# Reload the current item at the new quality, keeping position. Only
|
|
# when something is actually loaded -- otherwise changing the default
|
|
# quality would start playing whatever was last selected.
|
|
if self._current_entry and self._playback_started:
|
|
pos = self.current_position()
|
|
self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0)
|
|
|
|
def _on_speed_changed(self, index: int) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["speed"] = self.speed_combo.itemData(index)
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_volume_changed(self, value: int) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["volume"] = value
|
|
except Exception:
|
|
pass
|
|
self._volume_apply_timer.start()
|
|
|
|
def _persist_volume(self) -> None:
|
|
ConfigManager.set("player.volume", self.volume_slider.value())
|
|
|
|
def _on_subs_toggled(self, checked: bool) -> None:
|
|
mpv_inst = self.video.mpv
|
|
if mpv_inst is not None:
|
|
try:
|
|
mpv_inst["sid"] = "auto" if checked else "no"
|
|
except Exception:
|
|
pass
|
|
|
|
# ---------------------------------------------------------- mouse/idle
|
|
|
|
def _on_wheel(self, notches: int, ctrl_held: bool) -> None:
|
|
if ctrl_held:
|
|
self.seek_relative(notches * SEEK_MEDIUM)
|
|
else:
|
|
self.nudge_volume(notches * 2)
|
|
|
|
def _on_mouse_activity(self) -> None:
|
|
"""Any movement over the video brings the controls back."""
|
|
if not self.controls_bar.isVisible():
|
|
self.controls_bar.show()
|
|
self.video.unsetCursor()
|
|
if self.is_fullscreen():
|
|
self._idle_timer.start()
|
|
|
|
def _hide_idle_controls(self) -> None:
|
|
# Only in fullscreen, and never while the pointer is on the bar
|
|
# itself -- hiding it out from under the cursor is hostile.
|
|
if not self.is_fullscreen() or self.controls_bar.underMouse():
|
|
return
|
|
try:
|
|
if bool(self.video.mpv["pause"]):
|
|
return # paused: leave the controls up
|
|
except Exception:
|
|
pass
|
|
self.controls_bar.hide()
|
|
self.video.setCursor(Qt.CursorShape.BlankCursor)
|
|
|
|
def on_fullscreen_changed(self, active: bool) -> None:
|
|
"""Called by FullscreenController so idle-hiding follows the state."""
|
|
self.fullscreen_btn.setIcon(
|
|
icons.icon("minimize" if active else "maximize", theme.ICON_ON_ACCENT)
|
|
)
|
|
self.fullscreen_btn.setToolTip(
|
|
_("player.act_leave_fullscreen") if active else _("player.fullscreen")
|
|
)
|
|
if active:
|
|
self.setFocus(Qt.FocusReason.OtherFocusReason)
|
|
self._idle_timer.start()
|
|
else:
|
|
self._idle_timer.stop()
|
|
self.controls_bar.show()
|
|
self.video.unsetCursor()
|
|
|
|
def contextMenuEvent(self, event) -> None:
|
|
menu = build_context_menu(self)
|
|
menu.exec(event.globalPos())
|
|
event.accept()
|
|
|
|
def resizeEvent(self, event) -> None:
|
|
super().resizeEvent(event)
|
|
# The buffering label floats over the video, so it has no layout to
|
|
# centre it. Positioned even while hidden, so it appears in the right
|
|
# place rather than jumping there.
|
|
hint = self.buffering_label.sizeHint()
|
|
self.buffering_label.setGeometry(
|
|
(self.video.width() - hint.width()) // 2,
|
|
(self.video.height() - hint.height()) // 2,
|
|
hint.width(),
|
|
hint.height(),
|
|
)
|
|
|
|
# ------------------------------------------------------------ fullscreen
|
|
|
|
def set_fullscreen_controller(self, controller) -> None:
|
|
"""
|
|
Injected by the main window, which owns the chrome being hidden.
|
|
|
|
The panel deliberately does not implement fullscreen itself: the old
|
|
version reparented itself into a top-level window, which destroyed the
|
|
QOpenGLContext underneath it twice per toggle and was the most direct
|
|
route to the render-context crash.
|
|
"""
|
|
self._fullscreen_controller = controller
|
|
|
|
def is_fullscreen(self) -> bool:
|
|
controller = self._fullscreen_controller
|
|
return bool(controller is not None and controller.is_active())
|
|
|
|
def toggle_fullscreen(self) -> None:
|
|
controller = self._fullscreen_controller
|
|
if controller is None:
|
|
logger.debug("Fullscreen requested but no controller is attached.")
|
|
return
|
|
controller.toggle()
|
|
|
|
def exit_fullscreen(self) -> None:
|
|
controller = self._fullscreen_controller
|
|
if controller is not None and controller.is_active():
|
|
controller.exit()
|
|
|
|
|
|
class PlayerUnavailablePanel(QWidget):
|
|
"""Placeholder shown when libmpv is missing; the rest of the app works."""
|
|
|
|
def __init__(self, hint: str, parent: Optional[QWidget] = None) -> None:
|
|
super().__init__(parent)
|
|
layout = QVBoxLayout(self)
|
|
layout.addStretch()
|
|
msg = QLabel(_("player.unavailable"))
|
|
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
msg.setStyleSheet("font-size: 16px; font-weight: bold;")
|
|
layout.addWidget(msg)
|
|
hint_label = QLabel(hint)
|
|
hint_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
hint_label.setWordWrap(True)
|
|
layout.addWidget(hint_label)
|
|
layout.addStretch()
|
|
|
|
|
|
def create_player_panel(parent: Optional[QWidget] = None) -> QWidget:
|
|
"""PlayerPanel when libmpv is available, otherwise the hint placeholder."""
|
|
available, hint = probe_player()
|
|
if available:
|
|
return PlayerPanel(parent)
|
|
return PlayerUnavailablePanel(hint, parent)
|
|
|
|
|
|
def _format_time(seconds: float) -> str:
|
|
seconds = int(max(0, seconds))
|
|
h, rem = divmod(seconds, 3600)
|
|
m, s = divmod(rem, 60)
|
|
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|