Add embedded mpv player (render API + QOpenGLWidget)
New ytsage/gui/ytsage_gui_player.py: - MpvRenderWidget hosts libmpv through MpvRenderContext in a QOpenGLWidget - works on native Wayland where wid-embedding cannot - with all mpv-thread callbacks marshalled to the GUI thread via queued signals only. - PlayerPanel adds transport controls: play/pause, seek slider, time display, quality selector (caps ytdl-format height and reloads in place), speed, volume (persisted), subtitle toggle, fullscreen (reparent to top-level window), and Space/F/Escape keys. - Playback resolves watch URLs through mpv's ytdl_hook pointed at the app-managed SHA256-verified yt-dlp binary (script-opts ytdl_hook-ytdl_path), inheriting cookies and proxy settings via ytdl-raw-options - stream freshness, DASH muxing and nsig handling stay in yt-dlp's hands. New ytsage/core/ytsage_mpv.py probes libmpv availability; without it the Watch UI shows a per-OS install hint and everything else works. python-mpv added to dependencies (libmpv itself is a system package). Config gains player.* and feed.* defaults. Verified on Wayland: real YouTube video streams with position/duration signals flowing and no thread-safety errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtGui import QOpenGLContext
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QStyle,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
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."""
|
||||
opts = []
|
||||
if ConfigManager.get("cookie_active"):
|
||||
if ConfigManager.get("cookie_source") == "file":
|
||||
path = ConfigManager.get("cookie_file_path")
|
||||
if path:
|
||||
opts.append(f"cookies={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={value}")
|
||||
proxy = ConfigManager.get("proxy_url")
|
||||
if proxy:
|
||||
opts.append(f"proxy={proxy}")
|
||||
return ",".join(opts)
|
||||
|
||||
|
||||
class MpvRenderWidget(QOpenGLWidget):
|
||||
"""QOpenGLWidget hosting a libmpv render context."""
|
||||
|
||||
# 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)
|
||||
_renderUpdateRequested = Signal()
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setMinimumHeight(240)
|
||||
self._mpv = None
|
||||
self._render_ctx = None
|
||||
self._renderUpdateRequested.connect(self.update, Qt.ConnectionType.QueuedConnection)
|
||||
self._create_mpv()
|
||||
|
||||
# ------------------------------------------------------------------ mpv
|
||||
|
||||
def _create_mpv(self) -> None:
|
||||
import mpv
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"vo": "libmpv",
|
||||
"ytdl": True,
|
||||
"keep_open": "yes",
|
||||
"idle": "yes",
|
||||
"osc": False,
|
||||
"input_default_bindings": False,
|
||||
}
|
||||
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)
|
||||
|
||||
@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_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||
if level in ("error", "fatal"):
|
||||
logger.error(f"mpv [{prefix}] {text.strip()}")
|
||||
if "ytdl" in prefix or level == "fatal":
|
||||
self.mpvError.emit(text.strip())
|
||||
else:
|
||||
logger.debug(f"mpv [{prefix}] {text.strip()}")
|
||||
|
||||
# --------------------------------------------------------------- OpenGL
|
||||
|
||||
def initializeGL(self) -> None:
|
||||
from mpv import MpvGlGetProcAddressFn, MpvRenderContext
|
||||
|
||||
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
|
||||
|
||||
self._get_proc_address = MpvGlGetProcAddressFn(get_proc_address)
|
||||
try:
|
||||
self._render_ctx = MpvRenderContext(
|
||||
self._mpv,
|
||||
"opengl",
|
||||
opengl_init_params={"get_proc_address": self._get_proc_address},
|
||||
)
|
||||
self._render_ctx.update_cb = self._renderUpdateRequested.emit # mpv thread
|
||||
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._render_ctx = None
|
||||
self.mpvError.emit(f"Video output initialization failed: {e}")
|
||||
|
||||
def paintGL(self) -> None:
|
||||
if self._render_ctx is None:
|
||||
return
|
||||
ratio = self.devicePixelRatioF()
|
||||
w = int(self.width() * ratio)
|
||||
h = int(self.height() * ratio)
|
||||
self._render_ctx.render(
|
||||
flip_y=True,
|
||||
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
try:
|
||||
if self._render_ctx is not None:
|
||||
self._render_ctx.free()
|
||||
self._render_ctx = None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error freeing mpv render context: {e}")
|
||||
try:
|
||||
if self._mpv is not None:
|
||||
self._mpv.terminate()
|
||||
self._mpv = None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error terminating mpv: {e}")
|
||||
|
||||
# ------------------------------------------------------------- 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
|
||||
|
||||
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_holder: Optional[QWidget] = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
|
||||
self.video = MpvRenderWidget(self)
|
||||
layout.addWidget(self.video, stretch=1)
|
||||
|
||||
self.title_label = QLabel("")
|
||||
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
|
||||
self.title_label.setWordWrap(True)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(8)
|
||||
controls.setContentsMargins(6, 0, 6, 4)
|
||||
|
||||
self.play_btn = QPushButton()
|
||||
self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
|
||||
self.play_btn.setFixedWidth(36)
|
||||
self.play_btn.clicked.connect(self.toggle_pause)
|
||||
controls.addWidget(self.play_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.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.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.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.valueChanged.connect(self._on_volume_changed)
|
||||
controls.addWidget(self.volume_slider)
|
||||
|
||||
self.subs_btn = QPushButton(_("player.subtitles"))
|
||||
self.subs_btn.setCheckable(True)
|
||||
self.subs_btn.toggled.connect(self._on_subs_toggled)
|
||||
controls.addWidget(self.subs_btn)
|
||||
|
||||
self.fullscreen_btn = QPushButton()
|
||||
self.fullscreen_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMaxButton))
|
||||
self.fullscreen_btn.setFixedWidth(36)
|
||||
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
||||
controls.addWidget(self.fullscreen_btn)
|
||||
|
||||
layout.addLayout(controls)
|
||||
|
||||
# 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._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)
|
||||
|
||||
# ------------------------------------------------------------ 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.nowPlayingChanged.emit(self._current_entry)
|
||||
|
||||
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
|
||||
|
||||
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 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:
|
||||
icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause
|
||||
self.play_btn.setIcon(self.style().standardIcon(icon))
|
||||
|
||||
@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
|
||||
if self._current_entry:
|
||||
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
|
||||
|
||||
def toggle_fullscreen(self) -> None:
|
||||
if self._fullscreen_holder is None:
|
||||
self._fullscreen_parent_layout = self.parentWidget().layout() if self.parentWidget() else None
|
||||
self._fullscreen_holder = self.parentWidget()
|
||||
self.setParent(None)
|
||||
self.setWindowFlags(Qt.WindowType.Window)
|
||||
self.showFullScreen()
|
||||
else:
|
||||
self.setWindowFlags(Qt.WindowType.Widget)
|
||||
if self._fullscreen_parent_layout is not None:
|
||||
self._fullscreen_parent_layout.addWidget(self)
|
||||
else:
|
||||
self.setParent(self._fullscreen_holder)
|
||||
self.showNormal()
|
||||
self.show()
|
||||
self._fullscreen_holder = None
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() == Qt.Key.Key_Escape and self._fullscreen_holder is not None:
|
||||
self.toggle_fullscreen()
|
||||
elif event.key() == Qt.Key.Key_Space:
|
||||
self.toggle_pause()
|
||||
elif event.key() == Qt.Key.Key_F:
|
||||
self.toggle_fullscreen()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
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}"
|
||||
Reference in New Issue
Block a user