Neither loudnorm (EBU R128) nor dynaudnorm work well for this use case — both are designed for continuous playback, not rapidly switching between random short clips with wildly different levels.
738 lines
30 KiB
Python
738 lines
30 KiB
Python
"""mpv-backed video player widget with transport controls."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import Qt, QTimer, Signal, Property, QPoint
|
|
from PySide6.QtGui import QColor, QIcon, QPixmap, QPainter, QPen, QBrush, QPolygon, QPainterPath, QFont
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QStyle,
|
|
QApplication,
|
|
)
|
|
|
|
|
|
def _paint_icon(shape: str, color: QColor, size: int = 16) -> QIcon:
|
|
"""Paint a media control icon using the given color."""
|
|
pix = QPixmap(size, size)
|
|
pix.fill(Qt.GlobalColor.transparent)
|
|
p = QPainter(pix)
|
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.setBrush(color)
|
|
s = size
|
|
|
|
if shape == "play":
|
|
p.drawPolygon(QPolygon([QPoint(3, 2), QPoint(3, s - 2), QPoint(s - 2, s // 2)]))
|
|
|
|
elif shape == "pause":
|
|
w = max(2, s // 4)
|
|
p.drawRect(2, 2, w, s - 4)
|
|
p.drawRect(s - 2 - w, 2, w, s - 4)
|
|
|
|
elif shape == "volume":
|
|
# Speaker cone
|
|
p.drawPolygon(QPolygon([
|
|
QPoint(1, s // 2 - 2), QPoint(4, s // 2 - 2),
|
|
QPoint(8, 2), QPoint(8, s - 2),
|
|
QPoint(4, s // 2 + 2), QPoint(1, s // 2 + 2),
|
|
]))
|
|
# Sound waves
|
|
p.setPen(QPen(color, 1.5))
|
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
|
path = QPainterPath()
|
|
path.arcMoveTo(8, 3, 6, s - 6, 45)
|
|
path.arcTo(8, 3, 6, s - 6, 45, -90)
|
|
p.drawPath(path)
|
|
|
|
elif shape == "muted":
|
|
p.drawPolygon(QPolygon([
|
|
QPoint(1, s // 2 - 2), QPoint(4, s // 2 - 2),
|
|
QPoint(8, 2), QPoint(8, s - 2),
|
|
QPoint(4, s // 2 + 2), QPoint(1, s // 2 + 2),
|
|
]))
|
|
p.setPen(QPen(color, 2))
|
|
p.drawLine(10, 4, s - 2, s - 4)
|
|
p.drawLine(10, s - 4, s - 2, 4)
|
|
|
|
elif shape == "loop":
|
|
p.setPen(QPen(color, 1.5))
|
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
|
path = QPainterPath()
|
|
path.arcMoveTo(2, 2, s - 4, s - 4, 30)
|
|
path.arcTo(2, 2, s - 4, s - 4, 30, 300)
|
|
p.drawPath(path)
|
|
# Arrowhead
|
|
p.setPen(Qt.PenStyle.NoPen)
|
|
p.setBrush(color)
|
|
end = path.currentPosition().toPoint()
|
|
p.drawPolygon(QPolygon([
|
|
end, QPoint(end.x() - 4, end.y() - 3), QPoint(end.x() + 1, end.y() - 4),
|
|
]))
|
|
|
|
elif shape == "once":
|
|
p.setPen(QPen(color, 1))
|
|
f = QFont()
|
|
f.setPixelSize(s - 2)
|
|
f.setBold(True)
|
|
p.setFont(f)
|
|
p.drawText(pix.rect(), Qt.AlignmentFlag.AlignCenter, "1\u00D7")
|
|
|
|
elif shape == "next":
|
|
p.drawPolygon(QPolygon([QPoint(2, 2), QPoint(2, s - 2), QPoint(s - 5, s // 2)]))
|
|
p.drawRect(s - 4, 2, 2, s - 4)
|
|
|
|
elif shape == "auto":
|
|
mid = s // 2
|
|
p.drawPolygon(QPolygon([QPoint(1, 3), QPoint(1, s - 3), QPoint(mid - 1, s // 2)]))
|
|
p.drawPolygon(QPolygon([QPoint(mid, 3), QPoint(mid, s - 3), QPoint(s - 2, s // 2)]))
|
|
|
|
p.end()
|
|
return QIcon(pix)
|
|
|
|
import mpv as mpvlib
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
from .mpv_gl import _MpvGLWidget
|
|
|
|
|
|
class _ClickSeekSlider(QSlider):
|
|
"""Slider that jumps to the clicked position instead of page-stepping."""
|
|
clicked_position = Signal(int)
|
|
|
|
def mousePressEvent(self, event):
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
val = QStyle.sliderValueFromPosition(
|
|
self.minimum(), self.maximum(), int(event.position().x()), self.width()
|
|
)
|
|
self.setValue(val)
|
|
self.clicked_position.emit(val)
|
|
super().mousePressEvent(event)
|
|
|
|
|
|
# -- Video Player (mpv backend via OpenGL render API) --
|
|
|
|
|
|
class VideoPlayer(QWidget):
|
|
"""Video player with transport controls, powered by mpv."""
|
|
|
|
play_next = Signal() # emitted when video ends in "Next" mode
|
|
media_ready = Signal() # emitted when media is loaded and duration is known
|
|
video_size = Signal(int, int) # (width, height) emitted when video dimensions are known
|
|
# Emitted whenever mpv fires its `playback-restart` event. This event
|
|
# arrives once after each loadfile (when playback actually starts
|
|
# producing frames) and once after each completed seek. The popout's
|
|
# state machine adapter listens to this signal and dispatches either
|
|
# VideoStarted or SeekCompleted depending on which state it's in
|
|
# (LoadingVideo vs SeekingVideo). The pre-state-machine code did not
|
|
# need this signal because it used a 500ms timestamp window to fake
|
|
# a seek-done edge; the state machine refactor replaces that window
|
|
# with this real event. Probe results in docs/POPOUT_REFACTOR_PLAN.md
|
|
# confirm exactly one event per load and one per seek.
|
|
playback_restart = Signal()
|
|
|
|
# QSS-controllable letterbox / pillarbox color. mpv paints the area
|
|
# around the video frame in this color instead of the default black,
|
|
# so portrait videos in a landscape preview slot (or vice versa) blend
|
|
# into the panel theme instead of sitting in a hard black box.
|
|
# Set via `VideoPlayer { qproperty-letterboxColor: ${bg}; }` in a theme.
|
|
# The class default below is just a fallback; __init__ replaces it
|
|
# with the current palette's Window color so systems without a custom
|
|
# QSS (e.g. Windows dark/light mode driven entirely by QPalette) get
|
|
# a letterbox that automatically matches the OS background.
|
|
_letterbox_color = QColor("#000000")
|
|
|
|
def _get_letterbox_color(self): return self._letterbox_color
|
|
def _set_letterbox_color(self, c):
|
|
self._letterbox_color = QColor(c) if isinstance(c, str) else c
|
|
self._apply_letterbox_color()
|
|
letterboxColor = Property(QColor, _get_letterbox_color, _set_letterbox_color)
|
|
|
|
def _apply_letterbox_color(self) -> None:
|
|
"""Push the current letterbox color into mpv. No-op if mpv hasn't
|
|
been initialized yet — _ensure_mpv() calls this after creating the
|
|
instance so a QSS-set property still takes effect on first use."""
|
|
if self._mpv is None:
|
|
return
|
|
try:
|
|
self._mpv['background'] = 'color'
|
|
self._mpv['background-color'] = self._letterbox_color.name()
|
|
except Exception:
|
|
pass
|
|
|
|
def __init__(self, parent: QWidget | None = None, embed_controls: bool = True) -> None:
|
|
"""
|
|
embed_controls: When True (default), the transport controls bar is
|
|
added to this VideoPlayer's own layout below the video — used by the
|
|
popout window which then reparents the bar to its overlay layer.
|
|
When False, the controls bar is constructed but never inserted into
|
|
any layout, leaving the embedded preview a clean video surface with
|
|
no transport controls visible. Use the popout for playback control.
|
|
"""
|
|
super().__init__(parent)
|
|
# Initialize the letterbox color from the current palette's Window
|
|
# role so dark/light mode (or any system without a custom QSS)
|
|
# gets a sensible default that matches the surrounding panel.
|
|
# The QSS qproperty-letterboxColor on the bundled themes still
|
|
# overrides this — Qt calls the setter during widget polish,
|
|
# which happens AFTER __init__ when the widget is shown.
|
|
from PySide6.QtGui import QPalette
|
|
self._letterbox_color = self.palette().color(QPalette.ColorRole.Window)
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
# Video surface — mpv renders via OpenGL render API
|
|
self._gl_widget = _MpvGLWidget()
|
|
layout.addWidget(self._gl_widget, stretch=1)
|
|
|
|
# mpv reference (set by _ensure_mpv)
|
|
self._mpv: mpvlib.MPV | None = None
|
|
|
|
# Controls bar — in preview panel this sits in the layout normally;
|
|
# in slideshow mode, FullscreenPreview reparents it as a floating overlay.
|
|
self._controls_bar = QWidget(self)
|
|
controls = QHBoxLayout(self._controls_bar)
|
|
controls.setContentsMargins(4, 2, 4, 2)
|
|
|
|
_btn_sz = 24
|
|
_fg = self.palette().buttonText().color()
|
|
|
|
def _icon_btn(shape: str, name: str, tip: str) -> QPushButton:
|
|
btn = QPushButton()
|
|
btn.setObjectName(name)
|
|
btn.setIcon(_paint_icon(shape, _fg))
|
|
btn.setFixedSize(_btn_sz, _btn_sz)
|
|
btn.setToolTip(tip)
|
|
return btn
|
|
|
|
self._icon_fg = _fg
|
|
self._play_icon = _paint_icon("play", _fg)
|
|
self._pause_icon = _paint_icon("pause", _fg)
|
|
|
|
self._play_btn = _icon_btn("play", "_ctrl_play", "Play / Pause (Space)")
|
|
self._play_btn.clicked.connect(self._toggle_play)
|
|
controls.addWidget(self._play_btn)
|
|
|
|
self._time_label = QLabel("0:00")
|
|
self._time_label.setMaximumWidth(45)
|
|
controls.addWidget(self._time_label)
|
|
|
|
self._seek_slider = _ClickSeekSlider(Qt.Orientation.Horizontal)
|
|
self._seek_slider.setRange(0, 0)
|
|
self._seek_slider.sliderMoved.connect(self._seek)
|
|
self._seek_slider.clicked_position.connect(self._seek)
|
|
controls.addWidget(self._seek_slider, stretch=1)
|
|
|
|
self._duration_label = QLabel("0:00")
|
|
self._duration_label.setMaximumWidth(45)
|
|
controls.addWidget(self._duration_label)
|
|
|
|
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self._vol_slider.setRange(0, 100)
|
|
self._vol_slider.setValue(50)
|
|
self._vol_slider.setFixedWidth(60)
|
|
self._vol_slider.valueChanged.connect(self._set_volume)
|
|
controls.addWidget(self._vol_slider)
|
|
|
|
self._vol_icon = _paint_icon("volume", _fg)
|
|
self._muted_icon = _paint_icon("muted", _fg)
|
|
|
|
self._mute_btn = _icon_btn("volume", "_ctrl_mute", "Mute / Unmute")
|
|
self._mute_btn.clicked.connect(self._toggle_mute)
|
|
controls.addWidget(self._mute_btn)
|
|
|
|
self._autoplay = True
|
|
self._auto_icon = _paint_icon("auto", _fg)
|
|
self._autoplay_btn = _icon_btn("auto", "_ctrl_autoplay", "Auto-play videos when selected")
|
|
self._autoplay_btn.setCheckable(True)
|
|
self._autoplay_btn.setChecked(True)
|
|
self._autoplay_btn.clicked.connect(self._toggle_autoplay)
|
|
self._autoplay_btn.hide()
|
|
controls.addWidget(self._autoplay_btn)
|
|
|
|
self._loop_icons = {
|
|
0: _paint_icon("loop", _fg),
|
|
1: _paint_icon("once", _fg),
|
|
2: _paint_icon("next", _fg),
|
|
}
|
|
self._loop_state = 0 # 0=Loop, 1=Once, 2=Next
|
|
self._loop_btn = _icon_btn("loop", "_ctrl_loop", "Loop / Once / Next")
|
|
self._loop_btn.clicked.connect(self._cycle_loop)
|
|
controls.addWidget(self._loop_btn)
|
|
|
|
# NO styleSheet here. The popout (FullscreenPreview) re-applies its
|
|
# own `_slideshow_controls` overlay styling after reparenting the
|
|
# bar to its central widget — see FullscreenPreview.__init__ — so
|
|
# the popout still gets the floating dark-translucent look. The
|
|
# embedded preview leaves the bar unstyled so it inherits the
|
|
# panel theme and visually matches the Bookmark/Save/BL Tag bar
|
|
# at the top of the panel rather than looking like a stamped-in
|
|
# overlay box.
|
|
if embed_controls:
|
|
layout.addWidget(self._controls_bar)
|
|
|
|
# Responsive hiding: watch controls bar resize and hide widgets
|
|
# that don't fit at narrow widths.
|
|
self._controls_bar.installEventFilter(self)
|
|
|
|
self._eof_pending = False
|
|
# Stale-eof suppression window. mpv emits `eof-reached=True`
|
|
# whenever a file ends — including via `command('stop')` —
|
|
# and the observer fires asynchronously on mpv's event thread.
|
|
# When set_media swaps to a new file, the previous file's stop
|
|
# generates an eof event that can race with `play_file`'s
|
|
# `_eof_pending = False` reset and arrive AFTER it, sticking
|
|
# the bool back to True. The next `_poll` then runs
|
|
# `_handle_eof` and emits `play_next` in Loop=Next mode →
|
|
# auto-advance past the post the user wanted → SKIP.
|
|
#
|
|
# Fix: ignore eof events for `_eof_ignore_window_secs` after
|
|
# each `play_file` call. The race is single-digit ms, so
|
|
# 250ms is comfortably wide for the suppression and narrow
|
|
# enough not to mask a real EOF on the shortest possible
|
|
# videos (booru video clips are always >= 1s).
|
|
self._eof_ignore_until: float = 0.0
|
|
self._eof_ignore_window_secs: float = 0.25
|
|
|
|
# The legacy 500ms `_seek_pending_until` pin window that lived
|
|
# here was removed after `609066c` switched the slider seek
|
|
# to `'absolute+exact'`. With exact seek, mpv lands at the
|
|
# click position rather than at a keyframe before it, so the
|
|
# slider doesn't drag back through the missing time when
|
|
# `_poll` resumes reading `time_pos` after the seek. The pin
|
|
# was defense in depth for keyframe-rounding latency that no
|
|
# longer exists.
|
|
|
|
# Polling timer for position/duration/pause/eof state
|
|
self._poll_timer = QTimer(self)
|
|
self._poll_timer.setInterval(100)
|
|
self._poll_timer.timeout.connect(self._poll)
|
|
|
|
# Pending values from mpv observers (written from mpv thread)
|
|
self._pending_duration: float | None = None
|
|
self._media_ready_fired = False
|
|
self._current_file: str | None = None
|
|
# Last reported source video size — used to dedupe video-params
|
|
# observer firings so widget-driven re-emissions don't trigger
|
|
# repeated _fit_to_content calls (which would loop forever).
|
|
self._last_video_size: tuple[int, int] | None = None
|
|
# Pending mute state — survives the lazy mpv creation. The popout's
|
|
# video player is constructed with no mpv attached (mpv is wired
|
|
# in _ensure_mpv on first set_media), and main_window's open-popout
|
|
# state sync writes is_muted before mpv exists. Without a Python-
|
|
# side fallback the value would be lost — the setter would update
|
|
# button text but the actual mpv instance (created later) would
|
|
# spawn unmuted by default. _ensure_mpv replays this on creation.
|
|
self._pending_mute: bool = False
|
|
|
|
# Stream-record state: mpv's stream-record option tees its
|
|
# network stream into a .part file that gets promoted to the
|
|
# real cache path on clean EOF. Eliminates the parallel httpx
|
|
# download that used to race with mpv for the same bytes.
|
|
self._stream_record_tmp: Path | None = None
|
|
self._stream_record_target: Path | None = None
|
|
self._seeked_during_record: bool = False
|
|
|
|
def _ensure_mpv(self) -> mpvlib.MPV:
|
|
"""Set up mpv callbacks on first use. MPV instance is pre-created."""
|
|
if self._mpv is not None:
|
|
return self._mpv
|
|
self._mpv = self._gl_widget._mpv
|
|
self._mpv['loop-file'] = 'inf' # default to loop mode
|
|
self._mpv.volume = self._vol_slider.value()
|
|
self._mpv.mute = self._pending_mute
|
|
self._mpv.observe_property('duration', self._on_duration_change)
|
|
self._mpv.observe_property('eof-reached', self._on_eof_reached)
|
|
self._mpv.observe_property('video-params', self._on_video_params)
|
|
# Forward mpv's `playback-restart` event to the Qt-side signal so
|
|
# the popout's state machine adapter can dispatch VideoStarted /
|
|
# SeekCompleted events on the GUI thread. mpv's event_callback
|
|
# decorator runs on mpv's event thread; emitting a Qt Signal is
|
|
# thread-safe and the receiving slot runs on the connection's
|
|
# target thread (typically the GUI main loop via the default
|
|
# AutoConnection from the same-thread receiver).
|
|
@self._mpv.event_callback('playback-restart')
|
|
def _emit_playback_restart(_event):
|
|
self.playback_restart.emit()
|
|
self._pending_video_size: tuple[int, int] | None = None
|
|
# Push any QSS-set letterbox color into mpv now that the instance
|
|
# exists. The qproperty-letterboxColor setter is a no-op if mpv
|
|
# hasn't been initialized yet, so we have to (re)apply on init.
|
|
self._apply_letterbox_color()
|
|
return self._mpv
|
|
|
|
# -- Public API (used by app.py for state sync) --
|
|
|
|
@property
|
|
def volume(self) -> int:
|
|
return self._vol_slider.value()
|
|
|
|
@volume.setter
|
|
def volume(self, val: int) -> None:
|
|
self._vol_slider.setValue(val)
|
|
|
|
@property
|
|
def is_muted(self) -> bool:
|
|
if self._mpv:
|
|
return bool(self._mpv.mute)
|
|
return self._pending_mute
|
|
|
|
@is_muted.setter
|
|
def is_muted(self, val: bool) -> None:
|
|
self._pending_mute = val
|
|
if self._mpv:
|
|
self._mpv.mute = val
|
|
self._mute_btn.setIcon(self._muted_icon if val else self._vol_icon)
|
|
|
|
@property
|
|
def autoplay(self) -> bool:
|
|
return self._autoplay
|
|
|
|
@autoplay.setter
|
|
def autoplay(self, val: bool) -> None:
|
|
self._autoplay = val
|
|
self._autoplay_btn.setChecked(val)
|
|
self._autoplay_btn.setIcon(self._auto_icon if val else self._play_icon)
|
|
self._autoplay_btn.setToolTip("Autoplay on" if val else "Autoplay off")
|
|
|
|
@property
|
|
def loop_state(self) -> int:
|
|
return self._loop_state
|
|
|
|
@loop_state.setter
|
|
def loop_state(self, val: int) -> None:
|
|
self._loop_state = val
|
|
tips = ["Loop: repeat", "Once: stop at end", "Next: advance"]
|
|
self._loop_btn.setIcon(self._loop_icons[val])
|
|
self._loop_btn.setToolTip(tips[val])
|
|
self._autoplay_btn.setVisible(val == 2)
|
|
self._apply_loop_to_mpv()
|
|
|
|
def get_position_ms(self) -> int:
|
|
if self._mpv and self._mpv.time_pos is not None:
|
|
return int(self._mpv.time_pos * 1000)
|
|
return 0
|
|
|
|
def seek_to_ms(self, ms: int) -> None:
|
|
if self._mpv:
|
|
self._mpv.seek(ms / 1000.0, 'absolute+exact')
|
|
if self._stream_record_target is not None:
|
|
self._seeked_during_record = True
|
|
|
|
def play_file(self, path: str, info: str = "") -> None:
|
|
"""Play a file from a local path OR a remote http(s) URL.
|
|
|
|
URL playback is the fast path for uncached videos: rather than
|
|
waiting for `download_image` to finish writing the entire file
|
|
to disk before mpv touches it, the load flow hands mpv the
|
|
remote URL and lets mpv stream + buffer + render the first
|
|
frame in parallel with the cache-populating download. mpv's
|
|
first frame typically lands in 1-2s instead of waiting for
|
|
the full multi-MB transfer.
|
|
|
|
For URL paths we set the `referrer` per-file option from the
|
|
booru's hostname so CDNs that gate downloads on Referer don't
|
|
reject mpv's request — same logic our own httpx client uses
|
|
in `cache._referer_for`. python-mpv's `loadfile()` accepts
|
|
per-file `**options` kwargs that become `--key=value` overrides
|
|
for the duration of that file.
|
|
"""
|
|
m = self._ensure_mpv()
|
|
self._gl_widget.ensure_gl_init()
|
|
self._current_file = path
|
|
self._media_ready_fired = False
|
|
self._pending_duration = None
|
|
self._eof_pending = False
|
|
# Open the stale-eof suppression window. Any eof-reached event
|
|
# arriving from mpv's event thread within the next 250ms is
|
|
# treated as belonging to the previous file's stop and
|
|
# ignored — see the long comment at __init__'s
|
|
# `_eof_ignore_until` definition for the race trace.
|
|
import time as _time
|
|
self._eof_ignore_until = _time.monotonic() + self._eof_ignore_window_secs
|
|
self._last_video_size = None # reset dedupe so new file fires a fit
|
|
self._apply_loop_to_mpv()
|
|
|
|
# Clean up any leftover .part from a previous play_file that
|
|
# didn't finish (rapid clicks, popout closed mid-stream, etc).
|
|
self._discard_stream_record()
|
|
|
|
if path.startswith(("http://", "https://")):
|
|
from urllib.parse import urlparse
|
|
from ...core.cache import _referer_for, cached_path_for
|
|
referer = _referer_for(urlparse(path))
|
|
target = cached_path_for(path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = target.with_suffix(target.suffix + ".part")
|
|
m.loadfile(path, "replace",
|
|
referrer=referer,
|
|
stream_record=tmp.as_posix(),
|
|
demuxer_max_bytes="150MiB")
|
|
self._stream_record_tmp = tmp
|
|
self._stream_record_target = target
|
|
else:
|
|
m.loadfile(path)
|
|
if self._autoplay:
|
|
m.pause = False
|
|
else:
|
|
m.pause = True
|
|
self._play_btn.setIcon(self._pause_icon if not m.pause else self._play_icon)
|
|
self._poll_timer.start()
|
|
|
|
def stop(self) -> None:
|
|
self._discard_stream_record()
|
|
self._poll_timer.stop()
|
|
if self._mpv:
|
|
self._mpv.command('stop')
|
|
self._time_label.setText("0:00")
|
|
self._duration_label.setText("0:00")
|
|
self._seek_slider.setRange(0, 0)
|
|
self._play_btn.setIcon(self._play_icon)
|
|
|
|
def pause(self) -> None:
|
|
if self._mpv:
|
|
self._mpv.pause = True
|
|
self._play_btn.setIcon(self._play_icon)
|
|
|
|
def resume(self) -> None:
|
|
if self._mpv:
|
|
self._mpv.pause = False
|
|
self._play_btn.setIcon(self._pause_icon)
|
|
|
|
# -- Internal controls --
|
|
|
|
def eventFilter(self, obj, event):
|
|
if obj is self._controls_bar and event.type() == event.Type.Resize:
|
|
self._apply_responsive_layout()
|
|
return super().eventFilter(obj, event)
|
|
|
|
def _apply_responsive_layout(self) -> None:
|
|
"""Hide/show control elements based on available width."""
|
|
w = self._controls_bar.width()
|
|
# Breakpoints — hide wider elements first
|
|
show_volume = w >= 320
|
|
show_duration = w >= 240
|
|
show_time = w >= 200
|
|
self._vol_slider.setVisible(show_volume)
|
|
self._duration_label.setVisible(show_duration)
|
|
self._time_label.setVisible(show_time)
|
|
|
|
def _toggle_play(self) -> None:
|
|
if not self._mpv:
|
|
return
|
|
# If paused at end-of-file (Once mode after playback), seek back
|
|
# to the start so pressing play replays instead of doing nothing.
|
|
if self._mpv.pause:
|
|
try:
|
|
pos = self._mpv.time_pos
|
|
dur = self._mpv.duration
|
|
if pos is not None and dur is not None and dur > 0 and pos >= dur - 0.5:
|
|
self._mpv.command('seek', 0, 'absolute+exact')
|
|
except Exception:
|
|
pass
|
|
self._mpv.pause = not self._mpv.pause
|
|
self._play_btn.setIcon(self._play_icon if self._mpv.pause else self._pause_icon)
|
|
|
|
def _toggle_autoplay(self, checked: bool = True) -> None:
|
|
self._autoplay = self._autoplay_btn.isChecked()
|
|
self._autoplay_btn.setIcon(self._auto_icon if self._autoplay else self._play_icon)
|
|
self._autoplay_btn.setToolTip("Autoplay on" if self._autoplay else "Autoplay off")
|
|
|
|
def _cycle_loop(self) -> None:
|
|
self.loop_state = (self._loop_state + 1) % 3
|
|
|
|
def _apply_loop_to_mpv(self) -> None:
|
|
if not self._mpv:
|
|
return
|
|
if self._loop_state == 0: # Loop
|
|
self._mpv['loop-file'] = 'inf'
|
|
else: # Once or Next
|
|
self._mpv['loop-file'] = 'no'
|
|
|
|
def _seek(self, pos: int) -> None:
|
|
"""Seek to position in milliseconds (from slider).
|
|
|
|
Uses `'absolute+exact'` (frame-accurate seek) to match the
|
|
existing `seek_to_ms` and `_seek_relative` methods. mpv
|
|
decodes from the previous keyframe forward to the exact
|
|
target position, costing 30-100ms more than keyframe-only
|
|
seek but landing `time_pos` at the click position exactly.
|
|
|
|
See `609066c` for the drag-back race fix that introduced
|
|
this. The legacy 500ms `_seek_pending_until` pin window that
|
|
used to wrap this call was removed after the exact-seek
|
|
change made it redundant.
|
|
"""
|
|
if self._mpv:
|
|
self._mpv.seek(pos / 1000.0, 'absolute+exact')
|
|
if self._stream_record_target is not None:
|
|
self._seeked_during_record = True
|
|
|
|
def _seek_relative(self, ms: int) -> None:
|
|
if self._mpv:
|
|
self._mpv.seek(ms / 1000.0, 'relative+exact')
|
|
|
|
def _set_volume(self, val: int) -> None:
|
|
if self._mpv:
|
|
self._mpv.volume = val
|
|
|
|
def _toggle_mute(self) -> None:
|
|
if self._mpv:
|
|
self._mpv.mute = not self._mpv.mute
|
|
self._pending_mute = bool(self._mpv.mute)
|
|
self._mute_btn.setIcon(self._muted_icon if self._mpv.mute else self._vol_icon)
|
|
|
|
# -- mpv callbacks (called from mpv thread) --
|
|
|
|
def _on_video_params(self, _name: str, value) -> None:
|
|
"""Called from mpv thread when video dimensions become known."""
|
|
if isinstance(value, dict) and value.get('w') and value.get('h'):
|
|
new_size = (value['w'], value['h'])
|
|
# mpv re-fires video-params on output-area changes too. Dedupe
|
|
# against the source dimensions we last reported so resizing the
|
|
# popout doesn't kick off a fit→resize→fit feedback loop.
|
|
if new_size != self._last_video_size:
|
|
self._last_video_size = new_size
|
|
self._pending_video_size = new_size
|
|
|
|
def _on_eof_reached(self, _name: str, value) -> None:
|
|
"""Called from mpv thread when eof-reached changes.
|
|
|
|
Suppresses eof events that arrive within the post-play_file
|
|
ignore window — those are stale events from the previous
|
|
file's stop and would otherwise race the `_eof_pending=False`
|
|
reset and trigger a spurious play_next auto-advance.
|
|
"""
|
|
if value is True:
|
|
import time as _time
|
|
if _time.monotonic() < self._eof_ignore_until:
|
|
# Stale eof from a previous file's stop. Drop it.
|
|
return
|
|
self._eof_pending = True
|
|
|
|
def _on_duration_change(self, _name: str, value) -> None:
|
|
if value is not None and value > 0:
|
|
self._pending_duration = value
|
|
|
|
# -- Main-thread polling --
|
|
|
|
def _poll(self) -> None:
|
|
if not self._mpv:
|
|
return
|
|
# Position. After the `609066c` exact-seek fix and the
|
|
# subsequent removal of the `_seek_pending_until` pin window,
|
|
# this is just a straight read-and-write — `mpv.time_pos`
|
|
# equals the click position immediately after a slider seek
|
|
# because mpv decodes from the previous keyframe forward to
|
|
# the exact target before reporting it.
|
|
pos = self._mpv.time_pos
|
|
if pos is not None:
|
|
pos_ms = int(pos * 1000)
|
|
if not self._seek_slider.isSliderDown():
|
|
self._seek_slider.setValue(pos_ms)
|
|
self._time_label.setText(self._fmt(pos_ms))
|
|
|
|
# Duration (from observer)
|
|
dur = self._pending_duration
|
|
if dur is not None:
|
|
dur_ms = int(dur * 1000)
|
|
if self._seek_slider.maximum() != dur_ms:
|
|
self._seek_slider.setRange(0, dur_ms)
|
|
self._duration_label.setText(self._fmt(dur_ms))
|
|
if not self._media_ready_fired:
|
|
self._media_ready_fired = True
|
|
self.media_ready.emit()
|
|
|
|
# Pause state
|
|
paused = self._mpv.pause
|
|
expected_icon = self._play_icon if paused else self._pause_icon
|
|
if self._play_btn.icon().cacheKey() != expected_icon.cacheKey():
|
|
self._play_btn.setIcon(expected_icon)
|
|
|
|
# Video size (set by observer on mpv thread, emitted here on main thread)
|
|
if self._pending_video_size is not None:
|
|
w, h = self._pending_video_size
|
|
self._pending_video_size = None
|
|
self.video_size.emit(w, h)
|
|
|
|
# EOF (set by observer on mpv thread, handled here on main thread)
|
|
if self._eof_pending:
|
|
self._handle_eof()
|
|
|
|
def _handle_eof(self) -> None:
|
|
"""Handle end-of-file on the main thread."""
|
|
if not self._eof_pending:
|
|
return
|
|
self._eof_pending = False
|
|
self._finalize_stream_record()
|
|
if self._loop_state == 1: # Once
|
|
self.pause()
|
|
elif self._loop_state == 2: # Next
|
|
self.pause()
|
|
self.play_next.emit()
|
|
|
|
# -- Stream-record helpers --
|
|
|
|
def _discard_stream_record(self) -> None:
|
|
"""Remove any pending stream-record temp file without promoting."""
|
|
tmp = self._stream_record_tmp
|
|
self._stream_record_tmp = None
|
|
self._stream_record_target = None
|
|
self._seeked_during_record = False
|
|
if tmp is not None:
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
def _finalize_stream_record(self) -> None:
|
|
"""Promote the stream-record .part file to its final cache path.
|
|
|
|
Only promotes if: (a) there is a pending stream-record, (b) the
|
|
user did not seek during playback (seeking invalidates the file
|
|
because mpv may have skipped byte ranges), and (c) the .part
|
|
file exists and is non-empty.
|
|
"""
|
|
tmp = self._stream_record_tmp
|
|
target = self._stream_record_target
|
|
self._stream_record_tmp = None
|
|
self._stream_record_target = None
|
|
if tmp is None or target is None:
|
|
return
|
|
if self._seeked_during_record:
|
|
log.debug("Stream-record discarded (seek during playback): %s", tmp.name)
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
return
|
|
if not tmp.exists() or tmp.stat().st_size == 0:
|
|
log.debug("Stream-record .part missing or empty: %s", tmp.name)
|
|
return
|
|
try:
|
|
os.replace(tmp, target)
|
|
log.debug("Stream-record promoted: %s -> %s", tmp.name, target.name)
|
|
except OSError as e:
|
|
log.warning("Stream-record promote failed: %s", e)
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def _fmt(ms: int) -> str:
|
|
s = ms // 1000
|
|
m = s // 60
|
|
return f"{m}:{s % 60:02d}"
|
|
|
|
def destroy(self, *args, **kwargs) -> None:
|
|
self._poll_timer.stop()
|
|
self._gl_widget.cleanup()
|
|
self._mpv = None
|
|
super().destroy(*args, **kwargs)
|