9 Commits

Author SHA1 Message Date
Homer b07a56082a Fall back to English instead of showing raw keys
Every namespace the fork added -- main_tabs, feed, cards, watch, browse,
search, player, and now account -- exists only in en.json, because the thirteen
translated language files predate them. get_text() consults the embedded
fallback dict and then gives up and returns the key, so a non-English UI read
"main_tabs.watch" on its own tabs. This round made that worse by adding more
English-only keys.

The fallback dict now carries those namespaces verbatim from en.json. It does
not translate anything -- a German UI shows English for these strings -- but
nothing shows a dotted key, and keys added later degrade the same way instead
of breaking. Translations that do exist still win: buttons.download is still
"Herunterladen" in German.

Verified across all fourteen language files: no key in the fork's namespaces
resolves to itself in any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:22:08 +02:00
Homer 9c4749e49b Release 5.5.0
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>
2026-08-09 01:18:43 +02:00
Homer fd744a1a85 Make the YouTube account reachable, and applied without a restart
The cookie login was not missing, it was buried: Downloads tab -> Custom
Options -> the "Login with Cookies" tab, where Downloads is one tab of five.
The three features that depend on it -- the Feed's account mode, Search and
Browse -- are other tabs, with no route to it. show_cookie_login_dialog(),
which opens that dialog on the right tab, had no caller anywhere in the
codebase; it does now.

An account button sits in the corner of the tab bar, visible from every tab,
saying whether cookies are in use and where they came from. The Feed offers
the same thing next to its account mode rather than greying the option out and
leaving it at that, and the disabled entry now says why it is disabled.

Applying cookies used to require a restart before playback saw them:
_build_ytdl_raw_options was read once, when the player widget was built. A
cookiesChanged signal now reaches the account button, the Feed and the live
mpv handle, and the player reloads at its current position -- ytdl_hook
consults the option when it resolves a URL, so anything already playing keeps
the streams it resolved with. YtdlpClient needed no change: it re-reads config
per call.

Two bugs in the same function: the options are joined with commas and were
never escaped, so a cookie path or a proxy URL containing one silently ended
the option and started a bogus one; and geo_proxy_url was honoured by the
downloader but never passed to the player, so a geo-restricted video would
download and then refuse to play.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:15:21 +02:00
Homer 79a6f26c2f Open on the Feed, and refresh it without hammering yt-dlp
The tab order was Watch, Search, Feed, Browse, Downloads, and the app opened
on Watch -- which is where the other tabs send you, not somewhere you start.
It is now Feed, Search, Browse, Downloads, Watch, with icons. Routing is
unaffected: _tab_index_of resolves by widget identity, not position.

Refresh-on-open needed care rather than a call in showEvent. A local refresh
is one yt-dlp subprocess per subscribed channel, serially, so it is braked
four ways: only channels not seen for feed.auto_refresh_on_open_minutes (30,
0 to disable), at most eight per visit, once per interval per session, and
after a delay so it does not race the tab transition or first-run setup. The
cached feed is already on screen throughout. That is a new config key rather
than the dead auto_refresh_minutes, because existing configs store that as 0
and would have read as opting out of a feature that did not exist yet.

SmoothTabWidget gained the currentChanged signal, icons and a corner slot it
never had. Its set_current_index needed a re-entrancy flag, not just an index
check: setting the tab bar's index emits its currentChanged straight back into
the same method, and at that point the stack has not moved, so every switch
fired the activation hook twice.

The grid was cleared and rebuilt after every channel finished -- flicker, lost
scroll position and every thumbnail re-read from disk each time. merge_entries
keeps existing cards. While there, _relayout was re-adding cards the layout
already owned, so layout items accumulated on every Load more, and the resize
check compared against columnCount(), which never shrinks, so it relaid out on
every resize event.

Smaller things this exposed: feed errors were written into the label the next
success overwrote, so failures were invisible; switching to account mode left
the local videos on screen; cancel() had no callers, so a refresh outlived the
tab and the window; Browse's Subscribe never said Unsubscribe though it
toggles; "Play all" queued one page while claiming otherwise; and the feed
sorted publish times against wall-clock fetch times in one COALESCE, so the
last-refreshed channel floated to the top.

Feed is the first thing seen now, so an empty one says what to do about it
instead of showing a bare grid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:11:40 +02:00
Homer 27933dd495 Make the player answer the keyboard and the mouse
PlayerPanel.keyPressEvent handled Space, F and Escape and was dead code:
nothing in the player called setFocusPolicy, so focus went to whichever child
button, slider or combo box was first in the tab order, and that child ate the
keys. Fixing focus alone would not have been enough -- clicking play parks
focus on the play button, which then swallows Space -- so the control bar is
explicitly NoFocus and the bindings are QShortcuts with
WidgetWithChildrenShortcut context on the panel. That fires whichever
descendant holds focus, and does not reach the Search box or the URL field on
other tabs the way an application shortcut would.

mpv's own input stays disabled. With vo=libmpv there is no mpv-owned window,
so its input layer receives nothing; enabling default bindings would mean
hand-forwarding events through a Qt-to-mpv key-name table, handing mpv the
OSD and OSC that are switched off here, and letting `q` quit the core out from
under the Qt UI. One declarative action table now drives the shortcuts, the
context menu and the buttons.

There was no mouse handling at all. Click pauses, double-click goes
fullscreen -- via a doubleClickInterval timer, so a double-click does not also
pause on the way -- the wheel changes volume and Ctrl+wheel seeks, with
sub-notch deltas accumulated so a trackpad is not inert.

The control bar gained previous, next and mute. Mute and volume are driven by
observed mpv properties rather than assumed, so the icon follows a change made
by key, menu or mpv itself. Buffering was completely invisible: a stalled
stream showed a frozen frame for 25 seconds before the retry with nothing on
screen, so paused-for-cache now surfaces a label over the video.

WatchPage owns next/previous because it owns the queue; the panel only asks.
Previous restarts the current item when it is more than five seconds in, as
every other player does, and pushes the interrupted item back onto the head of
the queue rather than dropping it.

Verified: all 38 bindings install, every action is a no-op rather than an
exception with nothing loaded, focus policies are as intended, and a
fullscreen round trip drives the player state correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:04:16 +02:00
Homer 3aca43b372 Give the app icons, tooltips and a finished theme
There was no icon system at all. Transport buttons called
QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark
monochrome glyph -- painted onto the app's saturated red buttons at a fixed
36px with no text, that is the black square. Everything else called an icon
was an emoji baked into en.json, which is tofu wherever the emoji font is
missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument
to the new helper; that is the whole fix.

The SVGs are drawn here rather than vendored, which keeps a third-party
licence out of the tree, and they live in the module rather than as asset
files, which keeps them out of package-data and safe in a frozen build.

Tooltips were the other half: three widgets in the entire application had one
and nothing set an accessible name. Filling that in at the call sites would
have meant editing well over a hundred of them, most in upstream-owned files.
Instead one application-level event filter handles QEvent.Polish, which Qt
sends to every widget once before it is shown -- so it also reaches dialogs
built by upstream code, and survives the next merge. It maps placeholder
emoji to icons, fills empty tooltips from the button text, and logs icon-only
buttons that still have none so the gaps are findable.

StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing
else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips
and the horizontal scrollbar fell through to the platform style. EXTRA_QSS
covers them, in a fork-owned module appended at the one application site.
SmoothTabWidget names its frame "tabContent" with the comment "We draw border
on content instead" -- that rule existed only inside two dialogs, and now
exists for the main window too.

Two corrections to rules that were already there: the pressed style changed
the padding, shifting every label two pixels and clipping fixed-width icon
buttons, and checkboxes were fully rounded, which reads as a radio button
rather than an on/off toggle.

Verified by screenshot on the real display: tab bar, buttons, combo carets
and checkboxes all render as intended, and all 35 icons rasterise non-empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:58:38 +02:00
Homer 5ae2817eea Stop the player freeing a render context libmpv still uses
The segfault: Qt destroys and recreates a widget's QOpenGLContext whenever it
moves to another top-level window, then calls initializeGL() again. libmpv
permits one render context per handle, so the second creation failed with
"There is already a mpv_render_context set" -- and the except branch assigned
self._render_ctx = None, dropping the last Python reference to the first
context, which 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 while registered and the next frame
notification jumped into freed memory.

Two invariants fix it. initializeGL() now tears down any existing render
context first, so a second call is an ordinary recreation. Teardown clears
update_cb, calls free() with the GL context current, and only then drops the
reference -- and it is connected to QOpenGLContext.aboutToBeDestroyed, so it
runs before the GL context dies instead of never. The local reference during
teardown is load-bearing: it is what keeps the trampoline alive until free()
returns.

Three things were destroying that context. Fullscreen reparented the panel
into a new top-level window (twice per toggle) and put it back at the end of
the splitter, losing the pane layout; it now fullscreens the main window and
hides the chrome, reparenting nothing. The tab cross-fade and the dialog blur
both grab() the widget tree, which on an OpenGL surface forces a framebuffer
readback and returns black -- the fade is skipped for pages holding the video,
and dialogs dim rather than blur.

Verified on a real Wayland GL context: ten forced context destroy/recreate
cycles re-establish the render context every time, and the full app survives
tab switching, six fullscreen toggles and resizes with no render-context
error and a clean exit. Before this, the same startup dumped core.

Also here because they are one-line consequences of touching _create_mpv: an
explicit per-platform hwdec list ending in software decoding, and the restored
volume actually reaching mpv -- the slider set its value before connecting its
signal, so playback always started at 100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:48:18 +02:00
Homer 2420091a4e Check for SageTube updates, not YTSage's
The update check asked PyPI for the `ytsage` package's version, asked GitHub
for oop7/YTSage's release notes, and pointed the download button at upstream's
releases. None of that describes this program. Being reminded to install
YTSage was the visible symptom; the cause was that the check had never been
repointed when the fork was made.

It now reads SageTube's own releases from git.houmeres.sk. Gitea's release API
is shaped like GitHub's, so the dialog and the caller are unchanged -- the
class keeps its name and signal signature, and only its body moved out to
core/ytsage_app_update.py, which is fork-owned and will not conflict on the
next merge from upstream.

Upstream's inherited tags end in `b` (v5.3.0b and earlier). packaging reads
that as a beta marker, so they sort below v5.4.0 and a stable instance cannot
be handed one. Tag parsing is defensive anyway: one unparseable tag must not
take the whole check down with it.

Also: the check is rate-limited to once a day rather than every start, the
dialog gained a "Skip this version" that survives a restart, the thread is now
joined on close, and the About dialog says SageTube. The three binary updaters
-- yt-dlp, Deno, ffmpeg -- legitimately track their own upstreams and are
deliberately untouched; ytsage_app_update's docstring says so, because "update"
is an overloaded word in this codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:36:14 +02:00
Homer 089b144626 Correct the version and make the config merge deeply
The package version had been left at the scaffolded 0.1.0 while the released
tag was v5.4.0. Nothing in the code chose 0.1.0 -- it was npm-init's equivalent,
never updated -- and it made every version comparison meaningless: the window
title, the About dialog and the update check all read it.

ConfigManager merged a stored config over the defaults with dict.update(),
which is shallow. A config written by an older build carries partial "player"
and "feed" objects, and a shallow update replaces the whole nested default with
the partial one, so keys added since came back missing. The `or 15` and
`or "auto"` fallbacks at the call sites were load-bearing because of it. The
merge is now recursive, and keys present only in the stored file are kept so a
downgrade cannot destroy settings.

Configs now carry a config_version. A file written before 5.4.0 -- including
one inherited from an upstream YTSage install -- has its stored
check_app_updates cleared once, because that setting used to point at PyPI's
`ytsage` package and oop7/YTSage's releases, neither of which is this program.

The settings tab read a missing check_app_updates as enabled and persisted that
reading on OK, so merely opening Custom Options turned the checker back on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:31:56 +02:00
29 changed files with 3064 additions and 288 deletions
+173 -1
View File
@@ -8,7 +8,179 @@ Kept as the work happens, not assembled at release time. Format is
with its history. SageTube's own versions start below and are the ones this file with its history. SageTube's own versions start below and are the ones this file
records. records.
## Unreleased ## 5.5.0 — 2026-08-09
### Added
- **The YouTube account is findable.** Signing in with browser cookies was
reachable only via Downloads → *Custom Options**Login with Cookies* — the
last tab — while the features that need it (the Feed's account mode, Search,
Browse) are other tabs with no route to it, and the method that opens that
dialog on the right tab had no caller at all. There is now an account button
in the corner of the tab bar, visible from everywhere, showing whether
cookies are in use and which browser they came from. The Feed offers the same
thing beside its account mode instead of just greying it out, and now says
why it is unavailable.
- **Cookie changes take effect immediately.** The player read them once, when
it was built, so signing in only reached playback after restarting the app.
It now reloads at the current position.
- **The player responds to the keyboard and the mouse.** It had a key handler
for Space, F and Escape that could never fire — nothing in the player set a
focus policy, so focus always landed on a child button or slider, which
swallowed the keys. Now: Space/K play-pause, ←/→ ∓5s, J/L ∓10s, Shift+←/→
∓1s, `,`/`.` frame step, ↑/↓ volume, M mute, `[`/`]` speed with Backspace to
reset, C subtitles, F fullscreen, Escape to leave, 09 to jump by tenths,
Home/End, N/P for next and previous. Click pauses, double-click goes
fullscreen, the wheel changes volume and Ctrl+wheel seeks, and right-click
opens a menu built from the same table as the shortcuts. The bindings are
scoped to the player, so typing in the Search box or the URL field is
unaffected.
- **Previous, next and mute buttons**, and a buffering indicator — a stalled
stream previously showed a frozen frame for 25 seconds with nothing on
screen before the automatic retry.
- In fullscreen the controls and pointer fade out after a few idle seconds and
return on any movement. In a window they never hide, so the layout does not
jump.
- **Real icons.** The app had no icon system: transport buttons used the
platform style's dark monochrome glyphs painted on saturated red, and
everything else called an icon was an emoji baked into the English strings.
A drawn SVG set is now rendered through QtSvg and recoloured at load — Qt
stylesheets cannot recolour a `QIcon`, which is exactly why the old ones were
unreadable. Buttons across the Downloads tab gained icons too.
- **Tooltips and accessible names on every button.** Three widgets in the whole
application had a tooltip. An application-level polish filter now fills them
in as each button is first shown, and swaps placeholder emoji for icons —
which covers dialogs owned by upstream without editing them.
- **Skip this version** in the update dialog. The skipped version is never
offered again; later ones still are.
### Changed
- **The tab order is Feed → Search → Browse → Downloads → Watch**, each with an
icon, and the app opens on the Feed. Watch was first, which is where the
other tabs send you rather than somewhere you start.
- **Opening the Feed refreshes it**, but only channels not seen for 30 minutes
(`feed.auto_refresh_on_open_minutes`, 0 to switch off), at most eight per
visit, once per interval per session, and after a short delay. A refresh is
one yt-dlp subprocess per subscribed channel, so an unthrottled one would be
a lot of them. The cached feed still appears instantly; manual Refresh always
does everything.
- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default
`auto`), each ending in software decoding, so a machine with broken GPU
interop plays rather than showing a black frame. This does **not** silence
`Cannot load libcuda.so.1` — that comes from the driver stack below mpv and
appears with `hwdec=no` too.
- **The update check now looks at SageTube's own releases.** It queried PyPI's
`ytsage` package for the version and `oop7/YTSage` for the changelog, then
linked to upstream's downloads — a different program's release stream. It now
reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the
"Download update" button opens this repository's release page.
- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags
(`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be
offered one.
- The check now runs at most once a day rather than on every start.
- The About dialog and the update settings say SageTube rather than YTSage. The
"Based on YTSage by oop7" attribution link stays — it is the MIT credit.
- Configs now carry `config_version`. A file written before 5.4.0 — including one
inherited from an upstream YTSage install — has its stored `check_app_updates`
cleared once, so SageTube's own default applies rather than a setting that
pointed at another project's releases.
### Fixed
- **A non-English interface no longer shows raw keys.** Every string SageTube
added — the tab names, the feed, cards, watch, browse, search, player and
account text — exists only in `en.json`, because the thirteen translated files
predate the fork. `get_text` returned the key itself, so the tabs literally
read `main_tabs.watch`. Those namespaces are now in the embedded English
fallback, so a missing translation reads as English rather than as a dotted
key. Translating the language files remains a separate job.
- The error log filled with hundreds of `after creating texture: OpenGL error
INVALID_ENUM` lines per session. They come from the GL driver, playback
continues regardless, and they buried the errors that do matter, so they are
logged at debug level now.
- The player's cookie and proxy options were joined with commas and never
escaped, so a cookie path or a proxy URL containing one silently truncated
the option and produced a bogus one. The geo-bypass proxy was honoured by the
downloader but never passed to the player at all.
- **The rest of the interface is themed.** `StyleSheet.MAIN` styled the window,
inputs, buttons and tables and nothing else, so the main tab bar, combo
boxes, sliders, lists, menus, splitters, tooltips and the horizontal
scrollbar were drawn by the platform — the tab bar with white text forced
onto system-coloured tabs, tooltips in the system's light style on a black
app. All of them now match. `QFrame#tabContent` finally has the rule the tab
widget's own comment promised.
- Checkboxes were round, which reads as a radio button — "one of these" rather
than "on or off". They are square with a tick.
- Clicking any button shifted its label two pixels down and right, and clipped
the artwork on fixed-width icon buttons: the pressed style changed the
padding. The background change alone reads as pressed.
- The window icon shipped only at 48px and was upscaled everywhere; the 256px
master now ships beside it. Its fallback was the platform's download arrow
standing in for the application's identity.
- **The feed grid no longer flickers or loses your scroll position.** It was
cleared and rebuilt from scratch after every channel finished, re-reading
every thumbnail from disk each time; finished channels are now merged in and
existing cards left alone. The docstring's claim that it "fills
incrementally" is finally true.
- Card layout items accumulated: each `Load more` re-added cards the layout
already held, and the resize check compared against `columnCount()`, which
never shrinks — so it relaid out on every single resize event.
- Feed failures were invisible. The error was written into the same label the
next successful channel immediately overwrote; the count is now reported once
when the refresh ends.
- Switching the feed to account mode left the local videos on screen, so one
mode's contents appeared to be the other's.
- Leaving the Feed tab or closing the window cancels a running refresh.
`cancel()` existed and had no callers, so a sweep kept running and the window
could be closed while its thread was live.
- Browse's Subscribe button never said "Unsubscribe", although pressing it
toggles; its only feedback was a status line on a different tab.
- "Play all" quietly queued just the loaded page and claimed to be everything.
- The feed sorted by publish time and fetch time in the same expression, so the
most recently refreshed channel floated to the top regardless of how old its
videos were.
- **The player no longer crashes the app.** Qt destroys and recreates a
widget's OpenGL context whenever it moves to another top-level window, and
calls `initializeGL()` again. libmpv allows one render context per handle, so
the second creation failed — and the failure path dropped the last reference
to the *first* context while libmpv still held a function pointer into it.
Python then collected the callback trampoline under libmpv's feet and the
next frame notification jumped into freed memory. `initializeGL()` now tears
down first so recreation is clean, teardown clears the callback and frees
with the GL context current, and it is wired to
`QOpenGLContext.aboutToBeDestroyed` so it runs before the context goes away
rather than never. Verified over ten forced context destroy/recreate cycles.
- **Fullscreen no longer reparents the video.** It took the player out of its
layout and into a new top-level window, destroying the OpenGL context twice
per toggle — the most direct route to the crash above — and on the way back
re-added the panel at the end of the splitter, so the video reappeared beside
the queue with the pane sizes lost. The main window now goes fullscreen and
the chrome around the video is hidden instead. Nothing is reparented.
- **Switching tabs no longer risks the player.** The cross-fade grabbed a
screenshot of the outgoing page; on an OpenGL surface that forces a
framebuffer readback and returns black, so the "fade" was a black slab and
the readback endangered the context. Pages containing the video now switch
without the fade.
- Dialogs dimmed instead of blurred while the player exists, for the same
reason — every dialog in the app screenshotted the whole window.
- The saved volume never reached mpv: the slider set its restored value before
its change signal was connected, so playback always started at 100.
- **The app no longer offers upstream YTSage's releases as its own updates.**
`__version__` and `pyproject.toml` had been left at the scaffolded `0.1.0`
while the released tag was `v5.4.0`, so every comparison against upstream's
published version reported an update. Both now read `5.4.0`, the single
source of truth for the window title, the About dialog and the update check.
- `ConfigManager` merged a stored config over the defaults **shallowly**, so a
file written by an older build replaced whole nested objects: a stored
`"player"` or `"feed"` containing only some keys silently discarded every
default added since. The merge is now recursive. Keys present only in the
stored file are preserved, so downgrading cannot destroy settings.
- Opening *Custom Options* and clicking OK re-enabled the update checker, because
the settings tab read a missing `check_app_updates` as enabled and then
persisted that reading unconditionally.
## 5.4.0 — 2026-08-08
### Changed ### Changed
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sagetube" name = "sagetube"
version = "0.1.0" version = "5.5.0"
description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage." description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage."
authors = [ authors = [
{ name = "Houmeres", email = "admin@ecoposta.sk" }, { name = "Houmeres", email = "admin@ecoposta.sk" },
@@ -66,6 +66,7 @@ include = ["ytsage*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
ytsage = [ ytsage = [
"assets/Icon/icon.png", "assets/Icon/icon.png",
"assets/Icon/icon-256.png",
"assets/sound/notification.mp3", "assets/sound/notification.mp3",
"languages/*.json", "languages/*.json",
] ]
+10 -4
View File
@@ -1,8 +1,14 @@
""" """
YTSage - YouTube Video Downloader SageTube - watch-first YouTube client
A modern, user-friendly YouTube video downloader built with PySide6. Search, browse, subscribe and stream in an embedded mpv player, with the full
yt-dlp download feature set inherited from YTSage. Built with PySide6.
The version below is the single source of truth for the running application:
the window title, the About dialog and the update checker all read it. It must
match `version` in pyproject.toml and the repository's latest signed tag --
this package's tag line continues YTSage's, which is why it starts at 5.x.
""" """
__version__ = "0.1.0" __version__ = "5.5.0"
__author__ = "oop7" __author__ = "Houmeres"
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+201
View File
@@ -0,0 +1,201 @@
"""
Application update checks against SageTube's own releases
=========================================================
SageTube is a fork of oop7/YTSage. It keeps upstream's git history, its
internal `ytsage` package name and its inherited tags -- but it is a different
program with a different release stream, and this module is the line between
them.
**This checks SageTube. Nothing here touches upstream.** The three other
updaters in this codebase legitimately track their own upstreams and are
deliberately left alone:
- yt-dlp binary -> gui/ytsage_gui_dialogs/ytsage_dialogs_update.py
- Deno runtime -> core/ytsage_deno.py
- ffmpeg -> utils/ytsage_constants.py
They share only the word "update".
Gitea's release API is shaped like GitHub's -- `tag_name`, `body`, `html_url`,
`draft`, `prerelease` -- so the consuming code needs no special cases. The
repository is anonymously readable, so no token is involved.
Tag notes
---------
The repository carries upstream's tags up to `v5.3.0b` alongside SageTube's
own. `packaging` reads a trailing `b` as a beta marker (`5.3.0b` -> `5.3.0b0`),
so those inherited tags are correctly treated as pre-releases and are invisible
to a stable instance. Parsing is still defensive: one unparseable tag must not
take the whole check down.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import requests
from packaging.version import InvalidVersion, Version, parse as parse_version
from .. import __version__
from ..utils.ytsage_config_manager import ConfigManager
from ..utils.ytsage_logger import logger
API_BASE: str = "https://git.houmeres.sk/api/v1/repos/Houmeres/SageTube"
RELEASES_PAGE: str = "https://git.houmeres.sk/Houmeres/SageTube/releases"
DEFAULT_TIMEOUT: float = 8.0
#: Don't ask the forge more than once a day, however often the app is started.
CHECK_INTERVAL_SECONDS: int = 24 * 60 * 60
#: How many releases to pull when the beta channel is on.
BETA_PAGE_SIZE: int = 20
@dataclass(frozen=True)
class Release:
"""One published release, normalised."""
version: Version
tag: str
url: str
body: str
prerelease: bool
def _headers() -> Dict[str, str]:
return {
"Accept": "application/json",
"User-Agent": f"SageTube/{__version__}",
}
def _to_release(payload: Dict[str, Any]) -> Optional[Release]:
"""Normalise one API object, or None if it is unusable."""
tag = (payload.get("tag_name") or "").strip()
if not tag:
return None
try:
version = parse_version(tag.lstrip("vV"))
except (InvalidVersion, TypeError) as e:
# A hand-made or inherited tag that isn't a version. Skip it rather
# than letting it abort the whole check.
logger.debug(f"Ignoring unparseable release tag {tag!r}: {e}")
return None
return Release(
version=version,
tag=tag,
url=payload.get("html_url") or f"{RELEASES_PAGE}/tag/{tag}",
body=(payload.get("body") or "").strip(),
prerelease=bool(payload.get("prerelease")) or version.is_prerelease,
)
def _get(path: str, timeout: float, **params: Any) -> Optional[Any]:
"""GET a JSON endpoint. Returns None on any failure -- never raises."""
try:
response = requests.get(
f"{API_BASE}{path}",
headers=_headers(),
params=params or None,
timeout=timeout,
)
except requests.RequestException as e:
logger.debug(f"Update check request failed: {e}")
return None
if response.status_code == 404:
# No release matches (e.g. /releases/latest on a repo whose only
# releases are pre-releases). That is "nothing to offer", not an error.
logger.debug("Update check: no matching release published.")
return None
if response.status_code != 200:
logger.debug(f"Update check: API returned {response.status_code}")
return None
try:
return response.json()
except ValueError as e:
logger.debug(f"Update check: malformed JSON: {e}")
return None
def fetch_latest(include_prerelease: bool = False, timeout: float = DEFAULT_TIMEOUT) -> Optional[Release]:
"""
The newest published release, or None if there is nothing or the check failed.
With `include_prerelease` the whole release list is pulled and filtered
here rather than trusting server-side filters, because the highest
*version* is wanted -- not the most recently published, which is what the
API orders by.
"""
if not include_prerelease:
payload = _get("/releases/latest", timeout)
if not isinstance(payload, dict):
return None
release = _to_release(payload)
# /releases/latest should never return a draft or pre-release, but a
# stable instance must not be handed one even if it does.
if release is None or release.prerelease:
return None
return release
payload = _get("/releases", timeout, limit=BETA_PAGE_SIZE, draft=False)
if not isinstance(payload, list):
return None
candidates: List[Release] = []
for item in payload:
if not isinstance(item, dict) or item.get("draft"):
continue
release = _to_release(item)
if release is not None:
candidates.append(release)
if not candidates:
return None
return max(candidates, key=lambda r: r.version)
def is_newer(release: Release, current_version: str) -> bool:
"""
Whether `release` should be offered over the running version.
Honours the version the user chose to skip, so "Skip this version" is not
silently undone by the next start.
"""
try:
current = parse_version(str(current_version).lstrip("vV"))
except (InvalidVersion, TypeError):
logger.debug(f"Running version {current_version!r} is unparseable; skipping update check.")
return False
if release.version <= current:
return False
skipped = ConfigManager.get("skipped_update_version")
if skipped and str(skipped) == str(release.version):
logger.debug(f"Update {release.tag} available but skipped by the user.")
return False
return True
def should_check(now: Optional[float] = None) -> bool:
"""Rate-limit to CHECK_INTERVAL_SECONDS, so a restart is not a new check."""
if now is None:
now = time.time()
try:
last = float(ConfigManager.get("last_update_check") or 0)
except (TypeError, ValueError):
last = 0.0
# A clock moved backwards must not lock the check out until it catches up.
if last > now:
return True
return (now - last) >= CHECK_INTERVAL_SECONDS
def mark_checked(now: Optional[float] = None) -> None:
ConfigManager.set("last_update_check", now if now is not None else time.time())
+82
View File
@@ -0,0 +1,82 @@
"""
The YouTube account indicator
=============================
SageTube can use browser cookies to reach a signed-in YouTube: the account
feed, age-restricted and members-only videos, and premium formats. All of that
worked -- but the only way to switch it on was Downloads tab -> Custom Options
-> the "Login with Cookies" tab, and Downloads is one tab among five. The three
features that need it (the Feed's account mode, Search, Browse) had no route to
it at all, and `show_cookie_login_dialog()` existed with no caller anywhere in
the codebase.
So this is a button in the tab bar's corner, visible from every tab, that says
whether cookies are active and opens the existing dialog. Nothing about the
cookie handling itself changes; it just became findable.
"""
from typing import Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QPushButton, QWidget
from . import ytsage_icons as icons
from . import ytsage_theme as theme
from ..utils.ytsage_config_manager import ConfigManager
from ..utils.ytsage_localization import _
class AccountStatusButton(QPushButton):
"""Shows the cookie state and opens the login dialog when pressed."""
loginRequested = Signal()
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setObjectName("accountStatusButton")
self.setCursor(Qt.CursorShape.PointingHandCursor)
# Not a call to action -- it sits in the chrome, so it should not
# compete with the accent-coloured buttons in the pages.
self.setStyleSheet(
f"""
QPushButton#accountStatusButton {{
background-color: transparent;
color: {theme.TEXT_MUTED};
border: 1px solid {theme.BORDER};
border-radius: 4px;
padding: 5px 12px;
margin: 4px 8px 4px 4px;
font-weight: normal;
}}
QPushButton#accountStatusButton:hover {{
color: {theme.TEXT};
background-color: {theme.SURFACE_HOVER};
border-color: {theme.BORDER_STRONG};
}}
QPushButton#accountStatusButton:pressed {{
background-color: {theme.BORDER};
}}
"""
)
self.clicked.connect(self.loginRequested)
self.refresh()
def refresh(self) -> None:
"""Re-read the cookie config and restate what it says."""
active = bool(ConfigManager.get("cookie_active"))
if not active:
self.setIcon(icons.icon("user", theme.TEXT_MUTED))
self.setText(_("account.signed_out"))
self.setToolTip(_("account.signed_out_tooltip"))
return
if (ConfigManager.get("cookie_source") or "browser") == "file":
source = _("account.source_file")
else:
browser = ConfigManager.get("cookie_browser") or "?"
profile = ConfigManager.get("cookie_browser_profile")
source = f"{browser}:{profile}" if profile else str(browser)
self.setIcon(icons.icon("user-check", theme.TEXT))
self.setText(_("account.signed_in", source=source))
self.setToolTip(_("account.signed_in_tooltip", source=source))
+26 -1
View File
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
from .ytsage_gui_cards import VideoCardGrid from .ytsage_gui_cards import VideoCardGrid
from ..core.ytsage_client import YtdlpWorker from ..core.ytsage_client import YtdlpWorker
from ..utils.ytsage_library_manager import LibraryManager
from ..utils.ytsage_localization import _ from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger from ..utils.ytsage_logger import logger
@@ -132,6 +133,7 @@ class BrowsePage(QWidget):
self.url_input.setMinimumHeight(38) self.url_input.setMinimumHeight(38)
bar.addWidget(self.url_input, stretch=1) bar.addWidget(self.url_input, stretch=1)
self.open_btn = QPushButton(_("browse.open")) self.open_btn = QPushButton(_("browse.open"))
self.open_btn.setToolTip(_("browse.open_tooltip"))
self.open_btn.setMinimumHeight(38) self.open_btn.setMinimumHeight(38)
self.open_btn.clicked.connect(self._open_from_input) self.open_btn.clicked.connect(self._open_from_input)
bar.addWidget(self.open_btn) bar.addWidget(self.open_btn)
@@ -142,14 +144,17 @@ class BrowsePage(QWidget):
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;") self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
header.addWidget(self.title_label, stretch=1) header.addWidget(self.title_label, stretch=1)
self.subscribe_btn = QPushButton(_("browse.subscribe")) self.subscribe_btn = QPushButton(_("browse.subscribe"))
self.subscribe_btn.setToolTip(_("browse.subscribe_tooltip"))
self.subscribe_btn.setVisible(False) self.subscribe_btn.setVisible(False)
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked) self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
header.addWidget(self.subscribe_btn) header.addWidget(self.subscribe_btn)
self.playall_btn = QPushButton(_("browse.play_all")) self.playall_btn = QPushButton(_("browse.play_all"))
self.playall_btn.setToolTip(_("browse.play_all_tooltip"))
self.playall_btn.setVisible(False) self.playall_btn.setVisible(False)
self.playall_btn.clicked.connect(self._on_play_all) self.playall_btn.clicked.connect(self._on_play_all)
header.addWidget(self.playall_btn) header.addWidget(self.playall_btn)
self.download_btn = QPushButton(_("browse.download_playlist")) self.download_btn = QPushButton(_("browse.download_playlist"))
self.download_btn.setToolTip(_("browse.download_playlist_tooltip"))
self.download_btn.setVisible(False) self.download_btn.setVisible(False)
self.download_btn.clicked.connect(self._on_download_playlist) self.download_btn.clicked.connect(self._on_download_playlist)
header.addWidget(self.download_btn) header.addWidget(self.download_btn)
@@ -235,6 +240,9 @@ class BrowsePage(QWidget):
} }
if self._channel_meta.get("title"): if self._channel_meta.get("title"):
self.title_label.setText(self._channel_meta["title"]) self.title_label.setText(self._channel_meta["title"])
# Now that the real channel id is known, the button can say whether
# this channel is already followed.
self.refresh_subscribe_state()
def _on_meta_finished(self) -> None: def _on_meta_finished(self) -> None:
if self._meta_worker is not None: if self._meta_worker is not None:
@@ -246,13 +254,30 @@ class BrowsePage(QWidget):
if not meta.get("title"): if not meta.get("title"):
meta["title"] = self.title_label.text() meta["title"] = self.title_label.text()
self.subscribeRequested.emit(meta) self.subscribeRequested.emit(meta)
# The handler toggles, so reflect the new state here. Previously the
# button always read "Subscribe" whichever way it had just gone, and
# the only feedback was a status line on a different tab.
self.refresh_subscribe_state()
def refresh_subscribe_state(self) -> None:
"""Make the button say what pressing it will now do."""
channel_id = self._channel_meta.get("channel_id") or self._channel_meta.get("url") or self._current_url
subscribed = bool(channel_id) and LibraryManager.is_subscribed(str(channel_id))
self.subscribe_btn.setText(_("browse.unsubscribe") if subscribed else _("browse.subscribe"))
self.subscribe_btn.setToolTip(
_("browse.unsubscribe_tooltip") if subscribed else _("browse.subscribe_tooltip")
)
def _on_play_all(self) -> None: def _on_play_all(self) -> None:
for entry in [c.entry for c in self.playlist_section.grid._cards]: cards = list(self.playlist_section.grid._cards)
for entry in [c.entry for c in cards]:
e = dict(entry) e = dict(entry)
if not e.get("url") and e.get("id"): if not e.get("url") and e.get("id"):
e["url"] = f"https://www.youtube.com/watch?v={e['id']}" e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
self._router.queueVideo.emit(e) self._router.queueVideo.emit(e)
# "Play all" queues what has been loaded, which is one page unless the
# user pressed Load more. Say so rather than implying the whole list.
self.playlist_section.status_label.setText(_("browse.queued_loaded", count=len(cards)))
def _on_download_playlist(self) -> None: def _on_download_playlist(self) -> None:
if self._current_url: if self._current_url:
+58 -1
View File
@@ -147,14 +147,17 @@ class VideoCard(QFrame):
actions.setSpacing(6) actions.setSpacing(6)
self.play_btn = QPushButton(_("cards.play")) self.play_btn = QPushButton(_("cards.play"))
self.play_btn.setToolTip(_("cards.play_tooltip"))
self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry())) self.play_btn.clicked.connect(lambda: self._router.playVideo.emit(self._routed_entry()))
actions.addWidget(self.play_btn) actions.addWidget(self.play_btn)
self.queue_btn = QPushButton(_("cards.queue")) self.queue_btn = QPushButton(_("cards.queue"))
self.queue_btn.setToolTip(_("cards.queue_tooltip"))
self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry())) self.queue_btn.clicked.connect(lambda: self._router.queueVideo.emit(self._routed_entry()))
actions.addWidget(self.queue_btn) actions.addWidget(self.queue_btn)
self.download_btn = QPushButton(_("cards.download")) self.download_btn = QPushButton(_("cards.download"))
self.download_btn.setToolTip(_("cards.download_tooltip"))
self.download_btn.clicked.connect(self._emit_download) self.download_btn.clicked.connect(self._emit_download)
actions.addWidget(self.download_btn) actions.addWidget(self.download_btn)
@@ -204,6 +207,9 @@ class VideoCardGrid(QScrollArea):
super().__init__(parent) super().__init__(parent)
self._router = router self._router = router
self._cards: List[VideoCard] = [] self._cards: List[VideoCard] = []
#: video id -> card, so merge_entries can tell new from existing
self._by_key: Dict[str, "VideoCard"] = {}
self._columns_in_use = 0
self.setWidgetResizable(True) self.setWidgetResizable(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
@@ -220,6 +226,7 @@ class VideoCardGrid(QScrollArea):
outer.addWidget(self._grid_widget) outer.addWidget(self._grid_widget)
self.load_more_btn = QPushButton(_("cards.load_more")) self.load_more_btn = QPushButton(_("cards.load_more"))
self.load_more_btn.setToolTip(_("cards.load_more_tooltip"))
self.load_more_btn.clicked.connect(self.loadMoreRequested.emit) self.load_more_btn.clicked.connect(self.loadMoreRequested.emit)
self.load_more_btn.setVisible(False) self.load_more_btn.setVisible(False)
outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter) outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter)
@@ -242,15 +249,56 @@ class VideoCardGrid(QScrollArea):
for entry in entries: for entry in entries:
card = VideoCard(entry, self._router, self._grid_widget) card = VideoCard(entry, self._router, self._grid_widget)
self._cards.append(card) self._cards.append(card)
self._by_key[self._key(entry)] = card
self._relayout() self._relayout()
self.load_more_btn.setVisible(show_load_more) self.load_more_btn.setVisible(show_load_more)
self.empty_label.setVisible(not self._cards) self.empty_label.setVisible(not self._cards)
@staticmethod
def _key(entry: Dict[str, Any]) -> str:
return str(entry.get("id") or entry.get("url") or id(entry))
def merge_entries(self, entries: List[Dict[str, Any]], prune: bool = False) -> None:
"""
Fold entries in, keeping cards that are already here.
set_entries() destroys and rebuilds every card, which during a feed
refresh happened once per channel: the grid flickered, the scroll
position was lost each time, and every thumbnail was re-read from
disk. Existing cards are left alone here, so only genuinely new
videos cost anything.
"""
incoming = {self._key(e): e for e in entries}
scroll = self.verticalScrollBar().value()
self.setUpdatesEnabled(False)
try:
for key, entry in incoming.items():
if key in self._by_key:
continue
card = VideoCard(entry, self._router, self._grid_widget)
self._cards.append(card)
self._by_key[key] = card
if prune:
for key in [k for k in self._by_key if k not in incoming]:
card = self._by_key.pop(key)
if card in self._cards:
self._cards.remove(card)
self._grid.removeWidget(card)
card.deleteLater()
self._relayout()
self.empty_label.setVisible(not self._cards)
finally:
self.setUpdatesEnabled(True)
self.verticalScrollBar().setValue(scroll)
def clear(self) -> None: def clear(self) -> None:
for card in self._cards: for card in self._cards:
self._grid.removeWidget(card) self._grid.removeWidget(card)
card.deleteLater() card.deleteLater()
self._cards = [] self._cards = []
self._by_key.clear()
self.empty_label.setVisible(True) self.empty_label.setVisible(True)
self.load_more_btn.setVisible(False) self.load_more_btn.setVisible(False)
@@ -265,10 +313,19 @@ class VideoCardGrid(QScrollArea):
def _relayout(self) -> None: def _relayout(self) -> None:
cols = self._columns() cols = self._columns()
# Drain first. addWidget on a card the layout already owns adds a
# second item for it, so the layout grew an extra entry per card on
# every append. takeAt detaches without deleting the widget.
while self._grid.count():
self._grid.takeAt(0)
for i, card in enumerate(self._cards): for i, card in enumerate(self._cards):
self._grid.addWidget(card, i // cols, i % cols) self._grid.addWidget(card, i // cols, i % cols)
self._columns_in_use = cols
def resizeEvent(self, event) -> None: def resizeEvent(self, event) -> None:
super().resizeEvent(event) super().resizeEvent(event)
if self._cards and self._columns() != self._grid.columnCount(): # Compared against what was actually laid out, not columnCount():
# QGridLayout never shrinks its column count, so that comparison
# stayed true forever and relaid out on every resize event.
if self._cards and self._columns() != self._columns_in_use:
self._relayout() self._relayout()
@@ -203,7 +203,7 @@ class AboutDialog(QDialog):
# Title and Version - more compact # Title and Version - more compact
title_label = QLabel( title_label = QLabel(
"<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>YTSage</span>" "<span style='color: #c90000; font-size: 28px; font-weight: 300; letter-spacing: 2px;'>SageTube</span>"
) )
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label) layout.addWidget(title_label)
@@ -688,9 +688,11 @@ class UpdaterTabWidget(QWidget):
beta_enabled = ConfigManager.get("check_beta_updates") or False beta_enabled = ConfigManager.get("check_beta_updates") or False
self.beta_updates_checkbox.setChecked(beta_enabled) self.beta_updates_checkbox.setChecked(beta_enabled)
# Load app update checker setting (default enabled for older configs) # Load app update checker setting. `is not False` would have read a
# missing key as enabled, and since this dialog persists the value
# unconditionally on OK, merely opening it re-enabled the checker.
app_updates_enabled = ConfigManager.get("check_app_updates") app_updates_enabled = ConfigManager.get("check_app_updates")
self.app_updates_checkbox.setChecked(app_updates_enabled is not False) self.app_updates_checkbox.setChecked(bool(app_updates_enabled))
# Set current selection based on saved settings # Set current selection based on saved settings
current_frequency = auto_settings["frequency"] current_frequency = auto_settings["frequency"]
+212 -14
View File
@@ -16,7 +16,7 @@ Browse page's Subscribe button routes here via the main window.
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PySide6.QtCore import QThread, Qt, Signal from PySide6.QtCore import QThread, QTimer, Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
@@ -38,6 +38,18 @@ from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger from ..utils.ytsage_logger import logger
#: Only refresh a channel automatically if it is at least this stale.
AUTO_REFRESH_DEFAULT_MINUTES = 30
#: Channels touched per automatic refresh. A manual Refresh still does all of
#: them; this only bounds what opening the tab can set off.
AUTO_REFRESH_BATCH = 8
#: Let the tab transition finish before spawning subprocesses.
AUTO_REFRESH_DELAY_MS = 1500
#: The automatic path gives up sooner than a manual refresh: nobody is
#: watching it, and a stuck channel must not block the rest.
AUTO_REFRESH_TIMEOUT = 45
class FeedRefreshWorker(QThread): class FeedRefreshWorker(QThread):
"""Sequentially refresh each subscription's recent uploads.""" """Sequentially refresh each subscription's recent uploads."""
@@ -45,10 +57,17 @@ class FeedRefreshWorker(QThread):
channelFailed = Signal(str, str) # channel_id, error channelFailed = Signal(str, str) # channel_id, error
allDone = Signal() allDone = Signal()
def __init__(self, subscriptions: List[Dict[str, Any]], per_channel: int, parent=None) -> None: def __init__(
self,
subscriptions: List[Dict[str, Any]],
per_channel: int,
parent=None,
timeout: Optional[int] = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self._subs = subscriptions self._subs = subscriptions
self._per_channel = per_channel self._per_channel = per_channel
self._timeout = timeout
self._cancelled = False self._cancelled = False
def cancel(self) -> None: def cancel(self) -> None:
@@ -60,8 +79,15 @@ class FeedRefreshWorker(QThread):
if self._cancelled: if self._cancelled:
break break
try: try:
kwargs = {}
if self._timeout is not None:
kwargs["timeout"] = self._timeout
entries = client.fetch_flat_entries( entries = client.fetch_flat_entries(
f"{sub['url'].rstrip('/')}/videos", start=1, end=self._per_channel, use_cache=False f"{sub['url'].rstrip('/')}/videos",
start=1,
end=self._per_channel,
use_cache=False,
**kwargs,
) )
LibraryManager.upsert_feed_items(sub["channel_id"], entries) LibraryManager.upsert_feed_items(sub["channel_id"], entries)
LibraryManager.mark_refreshed(sub["channel_id"]) LibraryManager.mark_refreshed(sub["channel_id"])
@@ -73,17 +99,27 @@ class FeedRefreshWorker(QThread):
class FeedPage(QWidget): class FeedPage(QWidget):
#: Emitted after subscribe/unsubscribe so other pages can re-read state.
subscriptionsChanged = Signal()
def __init__(self, router, parent: Optional[QWidget] = None) -> None: def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
self._router = router self._router = router
self._refresh_worker: Optional[FeedRefreshWorker] = None self._refresh_worker: Optional[FeedRefreshWorker] = None
self._account_worker: Optional[YtdlpWorker] = None self._account_worker: Optional[YtdlpWorker] = None
#: monotonic timestamp of the last automatic refresh this session
self._last_auto_refresh: float = 0.0
self._done_count = 0
self._failed_count = 0
self._total_count = 0
self._last_error = ""
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8) layout.setContentsMargins(8, 8, 8, 8)
bar = QHBoxLayout() bar = QHBoxLayout()
self.mode_combo = QComboBox() self.mode_combo = QComboBox()
self.mode_combo.setToolTip(_("feed.mode_tooltip"))
self.mode_combo.addItem(_("feed.mode_local"), "local") self.mode_combo.addItem(_("feed.mode_local"), "local")
self.mode_combo.addItem(_("feed.mode_account"), "account") self.mode_combo.addItem(_("feed.mode_account"), "account")
self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1) self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1)
@@ -91,9 +127,19 @@ class FeedPage(QWidget):
bar.addWidget(self.mode_combo) bar.addWidget(self.mode_combo)
self.refresh_btn = QPushButton(_("feed.refresh")) self.refresh_btn = QPushButton(_("feed.refresh"))
self.refresh_btn.setToolTip(_("feed.refresh_tooltip"))
self.refresh_btn.setProperty("sageIcon", "refresh")
self.refresh_btn.clicked.connect(self.refresh) self.refresh_btn.clicked.connect(self.refresh)
bar.addWidget(self.refresh_btn) bar.addWidget(self.refresh_btn)
# Account mode is disabled without cookies. Rather than leaving that
# as a dead end, offer the way out right beside it.
self.signin_btn = QPushButton(_("feed.sign_in"))
self.signin_btn.setToolTip(_("feed.sign_in_tooltip"))
self.signin_btn.setProperty("sageIcon", "user")
self.signin_btn.clicked.connect(self._router.cookieSetupRequested)
bar.addWidget(self.signin_btn)
self.status_label = QLabel("") self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #9aa0a6;") self.status_label.setStyleSheet("color: #9aa0a6;")
bar.addWidget(self.status_label, stretch=1) bar.addWidget(self.status_label, stretch=1)
@@ -115,8 +161,25 @@ class FeedPage(QWidget):
side_layout.addWidget(self.subs_list) side_layout.addWidget(self.subs_list)
splitter.addWidget(side) splitter.addWidget(side)
self.grid = VideoCardGrid(router, self) grid_host = QWidget()
splitter.addWidget(self.grid) grid_layout = QVBoxLayout(grid_host)
grid_layout.setContentsMargins(0, 0, 0, 0)
self.grid = VideoCardGrid(router, grid_host)
grid_layout.addWidget(self.grid, stretch=1)
# Feed is the first tab, so an empty feed is the first thing a new
# install shows. Say what to do about it.
self.empty_hint = QLabel("")
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_hint.setWordWrap(True)
self.empty_hint.setStyleSheet("color: #9aa0a6; padding: 24px; font-size: 13px;")
self.empty_hint.hide()
grid_layout.addWidget(self.empty_hint, stretch=1)
# The grid carries its own bare "Nothing here yet"; two empty states
# stacked on one page is one too many.
self.grid.empty_label.hide()
splitter.addWidget(grid_host)
splitter.setStretchFactor(0, 1) splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 4) splitter.setStretchFactor(1, 4)
splitter.setSizes([220, 900]) splitter.setSizes([220, 900])
@@ -140,6 +203,8 @@ class FeedPage(QWidget):
LibraryManager.subscribe(str(channel_id), meta.get("title") or str(channel_id), meta["url"], meta.get("avatar_url")) LibraryManager.subscribe(str(channel_id), meta.get("title") or str(channel_id), meta["url"], meta.get("avatar_url"))
self.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id)) self.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id))
self.reload_subscriptions() self.reload_subscriptions()
self._update_empty_state()
self.subscriptionsChanged.emit()
def reload_subscriptions(self) -> None: def reload_subscriptions(self) -> None:
self.subs_list.clear() self.subs_list.clear()
@@ -156,21 +221,68 @@ class FeedPage(QWidget):
else: else:
self._refresh_local() self._refresh_local()
# --------------------------------------------------- refresh on opening
def on_tab_activated(self) -> None:
"""
Called by the main window when the Feed tab becomes visible.
The cached grid is already on screen -- it is loaded in the
constructor and kept up to date -- so this only decides whether to go
and fetch. A refresh is one yt-dlp subprocess per subscribed channel,
so it is braked four ways: only channels that are actually stale, at
most one automatic run per interval per session, a batch cap, and a
short delay so it does not race the tab transition or first-run setup.
"""
if self._refresh_worker is not None or self._account_worker is not None:
return
if (self.mode_combo.currentData() or "local") != "local":
return
minutes = ConfigManager.get("feed.auto_refresh_on_open_minutes")
minutes = AUTO_REFRESH_DEFAULT_MINUTES if minutes is None else int(minutes)
if minutes <= 0:
return # opted out
now = time.monotonic()
if self._last_auto_refresh and (now - self._last_auto_refresh) < minutes * 60:
return
cutoff = time.time() - minutes * 60
stale = [s for s in LibraryManager.subscriptions() if not s.get("last_refreshed") or s["last_refreshed"] < cutoff]
if not stale:
return
self._last_auto_refresh = now
batch = stale[:AUTO_REFRESH_BATCH]
if len(stale) > len(batch):
logger.info(f"Feed auto-refresh: {len(batch)} of {len(stale)} stale channels this time.")
QTimer.singleShot(AUTO_REFRESH_DELAY_MS, lambda: self._refresh_local(batch, auto=True))
# --------------------------------------------------------------- local # --------------------------------------------------------------- local
def _refresh_local(self) -> None: def _refresh_local(self, subs: Optional[List[Dict[str, Any]]] = None, auto: bool = False) -> None:
if self._refresh_worker is not None: if self._refresh_worker is not None:
return return
if subs is None:
subs = LibraryManager.subscriptions() subs = LibraryManager.subscriptions()
if not subs: if not subs:
if not auto:
self.status_label.setText(_("feed.no_subscriptions")) self.status_label.setText(_("feed.no_subscriptions"))
return return
per_channel = int(ConfigManager.get("feed.per_channel_items") or 15) per_channel = int(ConfigManager.get("feed.per_channel_items") or 15)
self.refresh_btn.setEnabled(False) self.refresh_btn.setEnabled(False)
self.status_label.setText(_("feed.refreshing", done=0, total=len(subs))) self.status_label.setText(_("feed.refreshing", done=0, total=len(subs)))
self._done_count = 0 self._done_count = 0
self._failed_count = 0
self._last_error = ""
self._total_count = len(subs) self._total_count = len(subs)
self._refresh_worker = FeedRefreshWorker(subs, per_channel, parent=self) self._refresh_worker = FeedRefreshWorker(
subs,
per_channel,
parent=self,
timeout=AUTO_REFRESH_TIMEOUT if auto else None,
)
self._refresh_worker.channelDone.connect(self._on_channel_done) self._refresh_worker.channelDone.connect(self._on_channel_done)
self._refresh_worker.channelFailed.connect(self._on_channel_failed) self._refresh_worker.channelFailed.connect(self._on_channel_failed)
self._refresh_worker.allDone.connect(self._on_refresh_done) self._refresh_worker.allDone.connect(self._on_refresh_done)
@@ -179,22 +291,36 @@ class FeedPage(QWidget):
def _on_channel_done(self, channel_id: str) -> None: def _on_channel_done(self, channel_id: str) -> None:
self._done_count += 1 self._done_count += 1
self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count)) self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count))
self._load_cached_feed() # Merge only this channel's rows. Re-reading the whole feed here
# rebuilt every card after every channel: visible flicker, the scroll
# position lost each time, and thumbnails re-read from disk.
self._merge_channel(channel_id)
def _on_channel_failed(self, channel_id: str, error: str) -> None: def _on_channel_failed(self, channel_id: str, error: str) -> None:
self._done_count += 1 self._done_count += 1
self.status_label.setText(_("feed.channel_failed", error=error[:120])) self._failed_count += 1
# Do not write the error into the status line here: the next
# channelDone overwrites it immediately, so failures were invisible.
# It is reported once at the end instead.
self._last_error = error
def _on_refresh_done(self) -> None: def _on_refresh_done(self) -> None:
self.refresh_btn.setEnabled(True) self.refresh_btn.setEnabled(True)
if self._failed_count:
self.status_label.setText(
_("feed.refreshed_with_failures", count=self.grid.card_count(), failed=self._failed_count)
)
logger.warning(f"Feed refresh: {self._failed_count} channel(s) failed; last error: {self._last_error[:200]}")
else:
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count())) self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
self._update_empty_state()
if self._refresh_worker is not None: if self._refresh_worker is not None:
self._refresh_worker.deleteLater() self._refresh_worker.deleteLater()
self._refresh_worker = None self._refresh_worker = None
def _load_cached_feed(self) -> None: @staticmethod
items = LibraryManager.feed_items() def _to_entries(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
entries = [ return [
{ {
"id": it["video_id"], "id": it["video_id"],
"url": it["url"], "url": it["url"],
@@ -205,7 +331,41 @@ class FeedPage(QWidget):
} }
for it in items for it in items
] ]
self.grid.set_entries(entries)
def _load_cached_feed(self) -> None:
self.grid.set_entries(self._to_entries(LibraryManager.feed_items()))
self._update_empty_state()
def _merge_channel(self, channel_id: str) -> None:
"""Fold one channel's fresh rows into the grid without rebuilding it."""
try:
items = LibraryManager.feed_items(channel_id=channel_id)
except TypeError:
# Older signature without the filter: fall back to a full reload.
self._load_cached_feed()
return
self.grid.merge_entries(self._to_entries(items))
self._update_empty_state()
def _update_empty_state(self) -> None:
"""
Feed is the first tab now, so an empty one is the first thing a new
user sees. Say what to do about it rather than showing a blank grid.
"""
self.grid.empty_label.hide()
if self.grid.card_count() > 0:
self.empty_hint.hide()
self.grid.show()
return
self.empty_hint.setText(
_("feed.empty_no_subscriptions")
if not LibraryManager.subscriptions()
else _("feed.empty_not_refreshed")
)
# Hide the empty grid so the hint sits in the middle of the page
# rather than pinned under a large blank area.
self.grid.hide()
self.empty_hint.show()
# -------------------------------------------------------------- account # -------------------------------------------------------------- account
@@ -242,15 +402,53 @@ class FeedPage(QWidget):
model_item = self.mode_combo.model().item(account_index) model_item = self.mode_combo.model().item(account_index)
if model_item is not None: if model_item is not None:
model_item.setEnabled(cookies_on) model_item.setEnabled(cookies_on)
# Say *why* it is unavailable. It used to be greyed out with no
# explanation and no way to do anything about it.
model_item.setToolTip("" if cookies_on else _("feed.account_requires_cookies"))
if not cookies_on and self.mode_combo.currentData() == "account": if not cookies_on and self.mode_combo.currentData() == "account":
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local")) self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
# The way out of that dead end: offered only while it is one.
self.signin_btn.setVisible(not cookies_on)
def on_cookies_changed(self) -> None:
"""Cookies were applied elsewhere; account mode may now be usable."""
self._update_mode_availability()
def showEvent(self, event) -> None: def showEvent(self, event) -> None:
self._update_mode_availability() self._update_mode_availability()
super().showEvent(event) super().showEvent(event)
def hideEvent(self, event) -> None:
# Leaving the tab cancels an automatic refresh. cancel() existed and
# was never called, so a background sweep kept running -- and closing
# the window destroyed a live QThread.
self._cancel_refresh()
super().hideEvent(event)
def _cancel_refresh(self) -> None:
worker = self._refresh_worker
if worker is None:
return
worker.cancel()
if not worker.wait(3000):
logger.warning("Feed refresh did not stop in time.")
def shutdown(self) -> None:
"""Called on application close."""
self._cancel_refresh()
def _on_mode_changed(self) -> None: def _on_mode_changed(self) -> None:
ConfigManager.set("feed.mode", self.mode_combo.currentData()) mode = self.mode_combo.currentData()
ConfigManager.set("feed.mode", mode)
# Switching modes left the previous mode's items on screen, so the
# local feed appeared to be the account feed. Show what the new mode
# actually has.
if mode == "local":
self._load_cached_feed()
else:
self.grid.set_entries([])
self.status_label.setText(_("feed.account_needs_refresh"))
self._update_empty_state()
def _on_sub_activated(self, item: QListWidgetItem) -> None: def _on_sub_activated(self, item: QListWidgetItem) -> None:
sub = item.data(Qt.ItemDataRole.UserRole) sub = item.data(Qt.ItemDataRole.UserRole)
+171 -148
View File
@@ -5,11 +5,10 @@ import webbrowser
from pathlib import Path from pathlib import Path
import markdown import markdown
import requests
from packaging import version
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve, QPoint from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve, QPoint
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QButtonGroup, QButtonGroup,
@@ -35,6 +34,7 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor
from .. import __version__ as APP_VERSION from .. import __version__ as APP_VERSION
from ..core import ytsage_app_update as app_update # SageTube's own release check
from ..core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes from ..core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
from ..core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update, validate_video_url from ..core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update, validate_video_url
from ..core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions from ..core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions
@@ -56,6 +56,7 @@ from .ytsage_gui_analysis import AnalysisMixin
from .ytsage_smooth_tab_widget import SmoothTabWidget from .ytsage_smooth_tab_widget import SmoothTabWidget
from ..utils.ytsage_constants import ( from ..utils.ytsage_constants import (
ICON_PATH, ICON_PATH,
ICON_PATH_LARGE,
SOUND_PATH, SOUND_PATH,
SUBPROCESS_CREATIONFLAGS, SUBPROCESS_CREATIONFLAGS,
VIDEO_EXTENSIONS, VIDEO_EXTENSIONS,
@@ -67,142 +68,50 @@ from ..utils.ytsage_config_manager import ConfigManager
from ..utils.ytsage_localization import LocalizationManager, _ from ..utils.ytsage_localization import LocalizationManager, _
from ..utils.ytsage_history_manager import HistoryManager from ..utils.ytsage_history_manager import HistoryManager
from .ytsage_stylesheet import StyleSheet from .ytsage_stylesheet import StyleSheet
from .ytsage_theme import build_extra_qss
from concurrent.futures import ThreadPoolExecutor, as_completed from . import ytsage_icons as icons
from . import ytsage_theme as theme
class UpdateCheckThread(QThread): class UpdateCheckThread(QThread):
"""Background thread for checking application updates with parallel network requests.""" """
Background check for a newer **SageTube** release.
This used to compare against PyPI's `ytsage` package and link to
oop7/YTSage's releases -- upstream's release stream, not this program's.
Combined with a package version that had been left at 0.1.0, it reported an
update on every start and sent the user to another project's downloads.
The forge query lives in core/ytsage_app_update.py. This class is only the
thread wrapper, and keeps its name and `update_available` signature so the
dialog and the caller are untouched.
"""
update_available = Signal(str, str, str) # version, url, changelog update_available = Signal(str, str, str) # version, url, changelog
# Reduced timeouts for faster failure detection
PYPI_TIMEOUT = 8
GITHUB_TIMEOUT = 5
def __init__(self, current_version): def __init__(self, current_version):
super().__init__() super().__init__()
self.current_version = current_version self.current_version = current_version
def _fetch_pypi_version(self) -> tuple[str | None, str | None]:
"""Fetch latest version from PyPI. Returns (version, error)."""
try:
response = requests.get(
"https://pypi.org/pypi/ytsage/json",
timeout=self.PYPI_TIMEOUT,
)
response.raise_for_status()
pypi_data = response.json()
return pypi_data["info"]["version"], None
except requests.Timeout:
return None, "PyPI request timed out"
except requests.RequestException as e:
return None, f"PyPI request failed: {e}"
except Exception as e:
return None, f"Error parsing PyPI response: {e}"
def _fetch_github_changelog(self) -> str:
"""Fetch changelog from GitHub. Returns changelog text or fallback message."""
fallback = "View the full changelog on the [GitHub Releases](https://github.com/oop7/YTSage/releases) page."
try:
response = requests.get(
"https://api.github.com/repos/oop7/YTSage/releases/latest",
headers={"Accept": "application/vnd.github.v3+json"},
timeout=self.GITHUB_TIMEOUT,
)
if response.status_code == 200:
gh_data = response.json()
return gh_data.get("body", fallback) or fallback
return fallback
except Exception:
# Silently fallback if GitHub API fails (rate limiting, network issues, etc.)
return fallback
def _fetch_github_beta_version(self) -> tuple[str | None, str | None, str | None]:
"""Fetch latest version code from GitHub releases (including betas). Returns (version, tag, changelog)."""
try:
response = requests.get(
"https://api.github.com/repos/oop7/YTSage/releases",
headers={"Accept": "application/vnd.github.v3+json"},
timeout=self.GITHUB_TIMEOUT,
)
if response.status_code != 200:
logger.debug(f"GitHub Releases API returned {response.status_code}")
return None, None, None
releases = response.json()
if not releases:
return None, None, None
latest_release = None
highest_ver = version.parse("0.0.0")
for rel in releases:
tag = rel.get("tag_name", "")
ver_str = tag.lstrip("v")
try:
v = version.parse(ver_str)
if v > highest_ver:
highest_ver = v
latest_release = rel
except Exception:
continue
if latest_release:
return str(highest_ver), latest_release.get("tag_name"), latest_release.get("body")
return None, None, None
except Exception as e:
logger.debug(f"GitHub beta check error: {e}")
return None, None, None
def run(self): def run(self):
"""Check for updates using parallel network requests for better performance."""
try: try:
# Check for beta updates if enabled release = app_update.fetch_latest(
check_beta = ConfigManager.get("check_beta_updates") include_prerelease=bool(ConfigManager.get("check_beta_updates"))
)
app_update.mark_checked()
if check_beta: if release is None:
latest_ver_str, tag, changelog = self._fetch_github_beta_version() logger.debug("Update check: nothing newer published.")
return
if latest_ver_str and version.parse(latest_ver_str) > version.parse(self.current_version): if not app_update.is_newer(release, self.current_version):
release_url = f"https://github.com/oop7/YTSage/releases/tag/{tag}" logger.info(f"Update check: {self.current_version} is current (latest {release.tag}).")
if not changelog:
changelog = "View the full changelog on GitHub."
self.update_available.emit(latest_ver_str, release_url, changelog)
# Return if beta check completes (whether update found or not),
# effectively skipping PyPI check if beta is enabled.
# This ensures we don't downgrade or conflict.
return return
# Use ThreadPoolExecutor to make both requests in parallel changelog = release.body or _("update_dialog.changelog_unavailable")
# This reduces total wait time from potentially 15s to ~8s max logger.info(f"Update available: {release.tag}")
with ThreadPoolExecutor(max_workers=2) as executor: self.update_available.emit(str(release.version), release.url, changelog)
# Submit both tasks
pypi_future = executor.submit(self._fetch_pypi_version)
github_future = executor.submit(self._fetch_github_changelog)
# Get PyPI result (this is required)
latest_version, error = pypi_future.result()
if error:
logger.debug(f"Update check failed: {error}")
return
if not latest_version:
logger.debug("No version returned from PyPI")
return
# Compare versions
if version.parse(latest_version) > version.parse(self.current_version):
release_url = "https://github.com/oop7/YTSage/releases/latest"
# Get GitHub changelog (may already be complete due to parallel execution)
changelog = github_future.result()
self.update_available.emit(latest_version, release_url, changelog)
except Exception as e: except Exception as e:
# An update check is never worth taking the app down for.
logger.debug(f"Failed to check for updates: {e}") logger.debug(f"Failed to check for updates: {e}")
@@ -217,12 +126,18 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.version = APP_VERSION self.version = APP_VERSION
load_saved_path(self) load_saved_path(self)
# Load custom icon # Load custom icon. Both sizes go in so the window manager and taskbar
if ICON_PATH.exists(): # pick rather than upscale the 48px one.
self.setWindowIcon(QIcon(str(ICON_PATH))) app_icon = QIcon()
else: for path in (ICON_PATH, ICON_PATH_LARGE):
logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.") if path.exists():
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback app_icon.addFile(str(path))
if app_icon.isNull():
logger.warning(f"Icon file not found at {ICON_PATH}. Using a drawn fallback.")
# A drawn icon beats the platform's download arrow standing in for
# the application's identity.
app_icon = icons.icon("play-circle", theme.ACCENT, 64)
self.setWindowIcon(app_icon)
self.signals = SignalManager() self.signals = SignalManager()
self.download_paused = False self.download_paused = False
self.current_download = None self.current_download = None
@@ -279,7 +194,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Defer heavy start-up tasks to ensure UI renders immediately # Defer heavy start-up tasks to ensure UI renders immediately
QTimer.singleShot(100, self._perform_startup_checks) QTimer.singleShot(100, self._perform_startup_checks)
self.setStyleSheet(StyleSheet.MAIN) # StyleSheet.MAIN is upstream's and covers the window, inputs, buttons
# and tables. EXTRA_QSS adds everything it never styled -- the tab bar,
# combos, sliders, lists, menus, splitters, tooltips and the horizontal
# scrollbar -- and corrects two of its rules. Kept in a separate,
# fork-owned module so this stays a one-line change here.
self.setStyleSheet(StyleSheet.MAIN + build_extra_qss())
self.signals.update_progress.connect(self.update_progress_bar) self.signals.update_progress.connect(self.update_progress_bar)
# After adding format buttons # After adding format buttons
@@ -409,6 +329,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Analyze button with app's red theme # Analyze button with app's red theme
self.analyze_button = QPushButton(_("buttons.analyze")) self.analyze_button = QPushButton(_("buttons.analyze"))
self.analyze_button.setProperty("sageIcon", "search")
self.analyze_button.clicked.connect(self.analyze_url) self.analyze_button.clicked.connect(self.analyze_url)
self.analyze_button.setEnabled(False) # Disabled until URL is entered self.analyze_button.setEnabled(False) # Disabled until URL is entered
self.analyze_button.setMinimumHeight(42) self.analyze_button.setMinimumHeight(42)
@@ -530,33 +451,41 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Replace the two separate buttons with a single Custom Options button # Replace the two separate buttons with a single Custom Options button
self.custom_options_btn = QPushButton(_("buttons.custom_options")) self.custom_options_btn = QPushButton(_("buttons.custom_options"))
self.custom_options_btn.setProperty("sageIcon", "settings")
self.custom_options_btn.clicked.connect(self.show_custom_options) self.custom_options_btn.clicked.connect(self.show_custom_options)
self.about_btn = QPushButton(_("buttons.about")) self.about_btn = QPushButton(_("buttons.about"))
self.about_btn.setProperty("sageIcon", "info")
self.about_btn.clicked.connect(self.show_about_dialog) self.about_btn.clicked.connect(self.show_about_dialog)
self.history_btn = QPushButton(_("buttons.history")) self.history_btn = QPushButton(_("buttons.history"))
self.history_btn.setProperty("sageIcon", "clock")
self.history_btn.clicked.connect(self.show_history_dialog) self.history_btn.clicked.connect(self.show_history_dialog)
# Add new Time Range button # Add new Time Range button
self.time_range_btn = QPushButton(_("buttons.trim_video")) self.time_range_btn = QPushButton(_("buttons.trim_video"))
self.time_range_btn.setProperty("sageIcon", "scissors")
self.time_range_btn.clicked.connect(self.show_time_range_dialog) self.time_range_btn.clicked.connect(self.show_time_range_dialog)
# --- Rename Path Button to Settings Button --- # --- Rename Path Button to Settings Button ---
self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button self.settings_button = QPushButton(_("buttons.download_settings")) # Renamed button
self.settings_button.setProperty("sageIcon", "settings")
self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method self.settings_button.clicked.connect(self.show_download_settings_dialog) # Renamed method
self._update_settings_tooltip() self._update_settings_tooltip()
# --- End Settings Button --- # --- End Settings Button ---
self.download_btn = QPushButton(_("buttons.download")) self.download_btn = QPushButton(_("buttons.download"))
self.download_btn.setProperty("sageIcon", "download")
self.download_btn.clicked.connect(self.start_download) self.download_btn.clicked.connect(self.start_download)
# Add pause and cancel buttons # Add pause and cancel buttons
self.pause_btn = QPushButton(_("buttons.pause")) self.pause_btn = QPushButton(_("buttons.pause"))
self.pause_btn.setProperty("sageIcon", "pause")
self.pause_btn.clicked.connect(self.toggle_pause) self.pause_btn.clicked.connect(self.toggle_pause)
self.pause_btn.setVisible(False) # Hidden initially self.pause_btn.setVisible(False) # Hidden initially
self.cancel_btn = QPushButton(_("buttons.cancel")) self.cancel_btn = QPushButton(_("buttons.cancel"))
self.cancel_btn.setProperty("sageIcon", "x")
self.cancel_btn.clicked.connect(self.cancel_download) self.cancel_btn.clicked.connect(self.cancel_download)
self.cancel_btn.setVisible(False) # Hidden initially self.cancel_btn.setVisible(False) # Hidden initially
@@ -638,6 +567,8 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
from .ytsage_gui_router import AppRouter from .ytsage_gui_router import AppRouter
from .ytsage_gui_search import SearchPage from .ytsage_gui_search import SearchPage
from .ytsage_gui_watch import WatchPage from .ytsage_gui_watch import WatchPage
from .ytsage_gui_account import AccountStatusButton
from .ytsage_player_fullscreen import FullscreenController
self.router = AppRouter(self) self.router = AppRouter(self)
@@ -646,20 +577,66 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.feed_page = FeedPage(self.router, self) self.feed_page = FeedPage(self.router, self)
self.browse_page = BrowsePage(self.router, self) self.browse_page = BrowsePage(self.router, self)
# Feed first, Watch last. The feed is what you open the app to see;
# the player is where the other tabs send you, not somewhere you
# start. _tab_index_of resolves by widget identity, so the routing
# methods below are unaffected by this order.
self.main_tabs = SmoothTabWidget(self) self.main_tabs = SmoothTabWidget(self)
self.main_tabs.addTab(self.watch_page, _("main_tabs.watch")) self.main_tabs.addTab(self.feed_page, _("main_tabs.feed"), icons.icon("rss", theme.ICON))
self.main_tabs.addTab(self.search_page, _("main_tabs.search")) self.main_tabs.addTab(self.search_page, _("main_tabs.search"), icons.icon("search", theme.ICON))
self.main_tabs.addTab(self.feed_page, _("main_tabs.feed")) self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"), icons.icon("library", theme.ICON))
self.main_tabs.addTab(self.browse_page, _("main_tabs.browse")) self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"), icons.icon("download", theme.ICON))
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads")) self.main_tabs.addTab(self.watch_page, _("main_tabs.watch"), icons.icon("play", theme.ICON))
self.main_tabs.currentChanged.connect(self._on_main_tab_changed)
self.setCentralWidget(self.main_tabs) self.setCentralWidget(self.main_tabs)
# Fullscreen is driven from here because the chrome it hides -- the tab
# bar and the queue panel -- belongs to the shell, not to the player.
if isinstance(self.watch_page.player, PlayerPanel):
self.fullscreen_controller = FullscreenController(
self, self.main_tabs, self.watch_page, parent=self
)
self.watch_page.player.set_fullscreen_controller(self.fullscreen_controller)
self.fullscreen_controller.changed.connect(self.watch_page.player.on_fullscreen_changed)
self.router.playVideo.connect(self._route_play_video) self.router.playVideo.connect(self._route_play_video)
self.router.queueVideo.connect(self.watch_page.enqueue) self.router.queueVideo.connect(self.watch_page.enqueue)
self.router.downloadVideo.connect(self._route_download_video) self.router.downloadVideo.connect(self._route_download_video)
self.router.openChannel.connect(self._route_open_channel) self.router.openChannel.connect(self._route_open_channel)
self.router.openPlaylist.connect(self._route_open_playlist) self.router.openPlaylist.connect(self._route_open_playlist)
self.browse_page.subscribeRequested.connect(self.feed_page.subscribe_channel) self.browse_page.subscribeRequested.connect(self.feed_page.subscribe_channel)
# A new subscription changes what the Feed's empty state should say.
self.feed_page.subscriptionsChanged.connect(self.browse_page.refresh_subscribe_state)
# The cookie login had exactly one entry point -- Downloads tab ->
# Custom Options -> Login with Cookies -- while the features needing
# it live on other tabs. A corner button makes it reachable from all
# of them, and the Feed offers it where account mode is disabled.
self.account_button = AccountStatusButton(self.main_tabs)
self.account_button.loginRequested.connect(self.show_cookie_login_dialog)
self.main_tabs.setCornerWidget(self.account_button)
self.router.cookieSetupRequested.connect(self.show_cookie_login_dialog)
self.router.cookiesChanged.connect(self.account_button.refresh)
self.router.cookiesChanged.connect(self.feed_page.on_cookies_changed)
if isinstance(self.watch_page.player, PlayerPanel):
self.router.cookiesChanged.connect(self.watch_page.player.reload_auth)
def _on_main_tab_changed(self, index: int) -> None:
"""
Tell a page it has become visible.
Duck-typed rather than isinstance-checked so a page opts in by simply
defining the method. Preferred over showEvent, which also fires on
window restore and on first show.
"""
page = self.main_tabs.stack.widget(index)
hook = getattr(page, "on_tab_activated", None)
if hook is None:
return
try:
hook()
except Exception as e:
logger.debug(f"Tab activation hook failed: {e}")
def _tab_index_of(self, page) -> int: def _tab_index_of(self, page) -> int:
for i in range(self.main_tabs.stack.count()): for i in range(self.main_tabs.stack.count()):
@@ -1087,9 +1064,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
def check_for_updates(self) -> None: def check_for_updates(self) -> None:
"""Starts the update check in a background thread.""" """Starts the update check in a background thread."""
if ConfigManager.get("check_app_updates") is False: if not ConfigManager.get("check_app_updates"):
logger.info("App version checker is disabled in settings.") logger.info("App version checker is disabled in settings.")
return return
if not app_update.should_check():
logger.debug("App version checked recently; skipping.")
return
self.update_thread = UpdateCheckThread(self.version) self.update_thread = UpdateCheckThread(self.version)
self.update_thread.update_available.connect(self.show_update_dialog) self.update_thread.update_available.connect(self.show_update_dialog)
@@ -1181,9 +1161,15 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
remind_btn.clicked.connect(msg.close) remind_btn.clicked.connect(msg.close)
remind_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN) remind_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN)
skip_btn = QPushButton(_('update_dialog.skip_version'))
skip_btn.setToolTip(_('update_dialog.skip_version_tooltip', version=latest_version))
skip_btn.clicked.connect(lambda: self._skip_update_version(latest_version, msg))
skip_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN)
button_layout.addStretch() button_layout.addStretch()
button_layout.addWidget(download_btn) button_layout.addWidget(download_btn)
button_layout.addWidget(remind_btn) button_layout.addWidget(remind_btn)
button_layout.addWidget(skip_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
# Style the dialog with improved theme matching # Style the dialog with improved theme matching
@@ -1191,6 +1177,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.run_dialog_with_blur(msg) self.run_dialog_with_blur(msg)
def _skip_update_version(self, latest_version: str, dialog: QDialog) -> None:
"""Never offer this particular version again; later ones still appear."""
ConfigManager.set("skipped_update_version", str(latest_version))
logger.info(f"Update {latest_version} skipped by the user.")
dialog.close()
def open_release_page(self, url): def open_release_page(self, url):
webbrowser.open(url) webbrowser.open(url)
@@ -1257,6 +1249,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
except Exception as e: except Exception as e:
logger.debug(f"Watch page shutdown error: {e}") logger.debug(f"Watch page shutdown error: {e}")
# Stop a feed refresh before its QThread is destroyed with it.
if hasattr(self, "feed_page"):
try:
self.feed_page.shutdown()
except Exception as e:
logger.debug(f"Feed page shutdown error: {e}")
# Stop the analysis thread if it's running # Stop the analysis thread if it's running
if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning(): if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
logger.info("Stopping analysis thread...") logger.info("Stopping analysis thread...")
@@ -1266,6 +1265,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self._analysis_thread.terminate() self._analysis_thread.terminate()
self._analysis_thread.wait(1000) self._analysis_thread.wait(1000)
# Stop the app version check if it's still waiting on the forge.
# It holds no resources, but a QThread destroyed while running
# aborts the process on the way out.
if hasattr(self, "update_thread") and self.update_thread is not None and self.update_thread.isRunning():
logger.info("Stopping app update check thread...")
self.update_thread.quit()
if not self.update_thread.wait(3000):
logger.warning("Force terminating app update check thread...")
self.update_thread.terminate()
self.update_thread.wait(1000)
# Stop the auto-update thread if it's running # Stop the auto-update thread if it's running
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning(): if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
logger.info("Stopping auto-update thread...") logger.info("Stopping auto-update thread...")
@@ -1337,6 +1347,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
_("proxy.cleared_message"), _("proxy.cleared_message"),
) )
# This dialog's Cookies tab persists on OK too, and proxy settings
# reach the player by the same route, so announce both from here.
if hasattr(self, "router"):
self.router.cookiesChanged.emit()
def show_about_dialog(self) -> None: # ADDED METHOD HERE def show_about_dialog(self) -> None: # ADDED METHOD HERE
dialog = AboutDialog(self) dialog = AboutDialog(self)
self.run_dialog_with_blur(dialog) self.run_dialog_with_blur(dialog)
@@ -1715,6 +1730,11 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.cookie_file_path = None # Clear path if dialog accepted but no file selected self.cookie_file_path = None # Clear path if dialog accepted but no file selected
self.browser_cookies_option = None # Clear browser cookies too self.browser_cookies_option = None # Clear browser cookies too
# The dialog has already written the config; tell the rest of the
# app so signing in takes effect now rather than after a restart.
if hasattr(self, "router"):
self.router.cookiesChanged.emit()
def cancel_download(self) -> None: def cancel_download(self) -> None:
if self.current_download: if self.current_download:
self.current_download.cancelled = True self.current_download.cancelled = True
@@ -1931,25 +1951,28 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
def run_dialog_with_blur(self, dialog: QDialog) -> int: def run_dialog_with_blur(self, dialog: QDialog) -> int:
"""Run a dialog with a static background screenshot blur to avoid QPainter conflicts.""" """Run a dialog with a static background screenshot blur to avoid QPainter conflicts."""
# 1. Capture the current state of the window (screenshot) # The blur is a screenshot of the window. grab() forces a framebuffer
pixmap = self.grab() # readback on any QOpenGLWidget in the tree -- which returns black,
# and risks the GL context the embedded mpv player renders into. Every
# 2. Create the blur using a Graphics Scene method (much safer than QGraphicsBlurEffect on live widget) # dialog in the app goes through here, so when a GL surface is present
# However, for simplicity and performance with PySide6, we can just apply a blur to the image # dim instead of blurring: same effect, no screenshot, and faster.
# or use a simplified overlay. if self.findChild(QOpenGLWidget) is not None:
# Let's manually blur the pixmap or use a simpler transparent overlay if blur is too heavy manually. overlay = QWidget(self)
# Actually, using QGraphicsBlurEffect on a temporary QGraphicsScene rendering to a pixmap is a valid way overlay.setAutoFillBackground(True)
# to generate a single blurred frame. overlay.setStyleSheet("background-color: rgba(0, 0, 0, 150);")
else:
blurred_pixmap = self._apply_blur_to_pixmap(pixmap, radius=10) blurred_pixmap = self._apply_blur_to_pixmap(self.grab(), radius=10)
# 3. Create an overlay widget that covers the Main Window
overlay = QLabel(self) overlay = QLabel(self)
overlay.setPixmap(blurred_pixmap) overlay.setPixmap(blurred_pixmap)
overlay.setGeometry(0, 0, self.width(), self.height()) overlay.setGeometry(0, 0, self.width(), self.height())
overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) # Block mouse overlay.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) # Block mouse
overlay.show() overlay.show()
# The opacity effect below goes on the overlay, which is a *sibling*
# of the video widget. A QGraphicsEffect on any ancestor of a
# QOpenGLWidget is unsupported and renders it black -- do not move it.
# Animate overlay Fade In # Animate overlay Fade In
opacity_effect = QGraphicsOpacityEffect(overlay) opacity_effect = QGraphicsOpacityEffect(overlay)
overlay.setGraphicsEffect(opacity_effect) overlay.setGraphicsEffect(opacity_effect)
+669 -58
View File
@@ -16,23 +16,29 @@ callback on its own threads. Nothing in those callbacks may touch Qt
widgets - they only emit queued Qt signals. widgets - they only emit queued Qt signals.
""" """
import sys
import threading
import webbrowser
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtCore import QCoreApplication, Qt, QTimer, Signal, Slot
from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtGui import QOpenGLContext from PySide6.QtGui import QOpenGLContext
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QSlider, QSlider,
QStyle,
QVBoxLayout, QVBoxLayout,
QWidget, 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_mpv import probe_player
from ..core.ytsage_yt_dlp import get_yt_dlp_path from ..core.ytsage_yt_dlp import get_yt_dlp_path
from ..utils.ytsage_config_manager import ConfigManager from ..utils.ytsage_config_manager import ConfigManager
@@ -59,27 +65,96 @@ def _ytdl_format_for(height: Optional[int]) -> str:
def _build_ytdl_raw_options() -> str: def _build_ytdl_raw_options() -> str:
"""Mirror the app's cookie/proxy config into ytdl_hook raw options.""" """
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 = [] opts = []
if ConfigManager.get("cookie_active"): if ConfigManager.get("cookie_active"):
if ConfigManager.get("cookie_source") == "file": if ConfigManager.get("cookie_source") == "file":
path = ConfigManager.get("cookie_file_path") path = ConfigManager.get("cookie_file_path")
if path: if path:
opts.append(f"cookies={path}") opts.append(f"cookies={esc(path)}")
else: else:
browser = ConfigManager.get("cookie_browser") browser = ConfigManager.get("cookie_browser")
profile = ConfigManager.get("cookie_browser_profile") profile = ConfigManager.get("cookie_browser_profile")
if browser: if browser:
value = f"{browser}:{profile}" if profile else browser value = f"{browser}:{profile}" if profile else browser
opts.append(f"cookies-from-browser={value}") opts.append(f"cookies-from-browser={esc(value)}")
proxy = ConfigManager.get("proxy_url") proxy = ConfigManager.get("proxy_url")
if proxy: if proxy:
opts.append(f"proxy={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) 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): class MpvRenderWidget(QOpenGLWidget):
"""QOpenGLWidget hosting a libmpv render context.""" """
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 # Emitted from mpv threads; connected queued to GUI-thread slots
mpvPositionChanged = Signal(float) mpvPositionChanged = Signal(float)
@@ -87,22 +162,58 @@ class MpvRenderWidget(QOpenGLWidget):
mpvPausedChanged = Signal(bool) mpvPausedChanged = Signal(bool)
mpvEndReached = Signal(str) # end-file reason mpvEndReached = Signal(str) # end-file reason
mpvError = Signal(str) mpvError = Signal(str)
mpvMuteChanged = Signal(bool)
mpvVolumeChanged = Signal(float)
mpvBufferingChanged = Signal(bool)
_renderUpdateRequested = Signal() _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: def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(240) 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._mpv = None
self._render_ctx = None self._render_ctx = None
self._renderUpdateRequested.connect(self.update, Qt.ConnectionType.QueuedConnection) 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() 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 # ------------------------------------------------------------------ mpv
def _create_mpv(self) -> None: def _create_mpv(self) -> None:
import mpv import mpv
hwdec = ConfigManager.get("player.hwdec") or "auto"
kwargs: Dict[str, Any] = { kwargs: Dict[str, Any] = {
"vo": "libmpv", "vo": "libmpv",
"ytdl": True, "ytdl": True,
@@ -110,6 +221,11 @@ class MpvRenderWidget(QOpenGLWidget):
"idle": "yes", "idle": "yes",
"osc": False, "osc": False,
"input_default_bindings": 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() ytdlp_path = get_yt_dlp_path()
if str(ytdlp_path) != "yt-dlp": if str(ytdlp_path) != "yt-dlp":
@@ -124,6 +240,13 @@ class MpvRenderWidget(QOpenGLWidget):
self._mpv.observe_property("time-pos", self._on_time_pos) self._mpv.observe_property("time-pos", self._on_time_pos)
self._mpv.observe_property("duration", self._on_duration) self._mpv.observe_property("duration", self._on_duration)
self._mpv.observe_property("pause", self._on_pause) 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") @self._mpv.event_callback("end-file")
def _on_end_file(event): # mpv thread def _on_end_file(event): # mpv thread
@@ -146,19 +269,79 @@ class MpvRenderWidget(QOpenGLWidget):
if value is not None: if value is not None:
self.mpvPausedChanged.emit(bool(value)) 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: def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
message = text.strip()
if level in ("error", "fatal"): if level in ("error", "fatal"):
logger.error(f"mpv [{prefix}] {text.strip()}") 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": if "ytdl" in prefix or level == "fatal":
self.mpvError.emit(text.strip()) self.mpvError.emit(message)
else: else:
logger.debug(f"mpv [{prefix}] {text.strip()}") logger.debug(f"mpv [{prefix}] {message}")
# --------------------------------------------------------------- OpenGL # --------------------------------------------------------------- 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: def initializeGL(self) -> None:
from mpv import MpvGlGetProcAddressFn, MpvRenderContext 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): def get_proc_address(_ctx, name):
glctx = QOpenGLContext.currentContext() glctx = QOpenGLContext.currentContext()
if glctx is None: if glctx is None:
@@ -166,46 +349,171 @@ class MpvRenderWidget(QOpenGLWidget):
address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8")) address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8"))
return int(address) if address else 0 return int(address) if address else 0
self._get_proc_address = MpvGlGetProcAddressFn(get_proc_address) # 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: try:
self._render_ctx = MpvRenderContext( render_ctx = MpvRenderContext(
self._mpv, self._mpv,
"opengl", "opengl",
opengl_init_params={"get_proc_address": self._get_proc_address}, opengl_init_params={"get_proc_address": proc_addr_fn},
) )
self._render_ctx.update_cb = self._renderUpdateRequested.emit # mpv thread
except Exception as e: except Exception as e:
# Leave the widget black but keep the app alive (e.g. software GL # Leave the widget black but keep the app alive (e.g. software GL
# contexts that libmpv rejects) # contexts that libmpv rejects)
logger.error(f"Failed to create mpv render context: {e}") logger.error(f"Failed to create mpv render context: {e}")
self._render_ctx = None
self.mpvError.emit(f"Video output initialization failed: {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: def paintGL(self) -> None:
if self._render_ctx is 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 return
ratio = self.devicePixelRatioF() ratio = self.devicePixelRatioF()
w = int(self.width() * ratio) w = int(self.width() * ratio)
h = int(self.height() * ratio) h = int(self.height() * ratio)
self._render_ctx.render( if w <= 0 or h <= 0:
return
try:
render_ctx.render(
flip_y=True, flip_y=True,
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h}, 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: 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: try:
if self._render_ctx is not None: if self.context() is not None:
self._render_ctx.free() self.makeCurrent()
self._render_ctx = None try:
self._teardown_render_context()
finally:
self.doneCurrent()
else:
self._teardown_render_context()
except Exception as e: except Exception as e:
logger.debug(f"Error freeing mpv render context: {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: try:
if self._mpv is not None: mpv_inst.terminate()
self._mpv.terminate()
self._mpv = None
except Exception as e: except Exception as e:
logger.debug(f"Error terminating mpv: {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 # ------------------------------------------------------------- controls
@property @property
@@ -221,13 +529,17 @@ class PlayerPanel(QWidget):
playbackEnded = Signal(str) # end-file reason ("eof", "error", ...) playbackEnded = Signal(str) # end-file reason ("eof", "error", ...)
playerError = Signal(str) playerError = Signal(str)
nowPlayingChanged = Signal(dict) # entry dict of the current item 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: def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
self._current_entry: Dict[str, Any] = {} self._current_entry: Dict[str, Any] = {}
self._duration: float = 0.0 self._duration: float = 0.0
self._slider_down = False self._slider_down = False
self._fullscreen_holder: Optional[QWidget] = None self._fullscreen_controller = None
self._muted = False
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@@ -236,26 +548,55 @@ class PlayerPanel(QWidget):
self.video = MpvRenderWidget(self) self.video = MpvRenderWidget(self)
layout.addWidget(self.video, stretch=1) 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 = QLabel("")
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;") self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
self.title_label.setWordWrap(True) self.title_label.setWordWrap(True)
layout.addWidget(self.title_label) layout.addWidget(self.title_label)
controls = QHBoxLayout() # 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.setSpacing(8)
controls.setContentsMargins(6, 0, 6, 4) 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 = QPushButton()
self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.play_btn.setIcon(icons.icon("play", theme.ICON_ON_ACCENT))
self.play_btn.setFixedWidth(36) self.play_btn.setFixedWidth(36)
self.play_btn.setToolTip(_("player.play_pause"))
self.play_btn.clicked.connect(self.toggle_pause) self.play_btn.clicked.connect(self.toggle_pause)
controls.addWidget(self.play_btn) 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") self.time_label = QLabel("0:00 / 0:00")
controls.addWidget(self.time_label) controls.addWidget(self.time_label)
self.seek_slider = QSlider(Qt.Orientation.Horizontal) self.seek_slider = QSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 1000) 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.sliderPressed.connect(self._on_slider_pressed)
self.seek_slider.sliderReleased.connect(self._on_slider_released) self.seek_slider.sliderReleased.connect(self._on_slider_released)
controls.addWidget(self.seek_slider, stretch=1) controls.addWidget(self.seek_slider, stretch=1)
@@ -266,6 +607,7 @@ class PlayerPanel(QWidget):
default_q = ConfigManager.get("player.default_quality") default_q = ConfigManager.get("player.default_quality")
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0) idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
self.quality_combo.setCurrentIndex(idx) self.quality_combo.setCurrentIndex(idx)
self.quality_combo.setToolTip(_("player.quality_tooltip"))
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed) self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
controls.addWidget(self.quality_combo) controls.addWidget(self.quality_combo)
@@ -273,6 +615,7 @@ class PlayerPanel(QWidget):
for s in SPEED_CHOICES: for s in SPEED_CHOICES:
self.speed_combo.addItem(f"{s:g}x", s) self.speed_combo.addItem(f"{s:g}x", s)
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0)) 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) self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
controls.addWidget(self.speed_combo) controls.addWidget(self.speed_combo)
@@ -280,21 +623,31 @@ class PlayerPanel(QWidget):
self.volume_slider.setRange(0, 100) self.volume_slider.setRange(0, 100)
self.volume_slider.setFixedWidth(90) self.volume_slider.setFixedWidth(90)
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100)) 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.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) controls.addWidget(self.volume_slider)
self.subs_btn = QPushButton(_("player.subtitles")) self.subs_btn = QPushButton(_("player.subtitles"))
self.subs_btn.setCheckable(True) self.subs_btn.setCheckable(True)
self.subs_btn.setToolTip(_("player.subtitles_tooltip"))
self.subs_btn.toggled.connect(self._on_subs_toggled) self.subs_btn.toggled.connect(self._on_subs_toggled)
controls.addWidget(self.subs_btn) controls.addWidget(self.subs_btn)
self.fullscreen_btn = QPushButton() self.fullscreen_btn = QPushButton()
self.fullscreen_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TitleBarMaxButton)) self.fullscreen_btn.setIcon(icons.icon("maximize", theme.ICON_ON_ACCENT))
self.fullscreen_btn.setFixedWidth(36) self.fullscreen_btn.setFixedWidth(36)
self.fullscreen_btn.setToolTip(_("player.fullscreen"))
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
controls.addWidget(self.fullscreen_btn) controls.addWidget(self.fullscreen_btn)
layout.addLayout(controls) layout.addWidget(self.controls_bar)
# mpv-thread signals arrive queued on the GUI thread # mpv-thread signals arrive queued on the GUI thread
self.video.mpvPositionChanged.connect(self._on_position, Qt.ConnectionType.QueuedConnection) self.video.mpvPositionChanged.connect(self._on_position, Qt.ConnectionType.QueuedConnection)
@@ -302,6 +655,9 @@ class PlayerPanel(QWidget):
self.video.mpvPausedChanged.connect(self._on_paused_changed, 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.mpvEndReached.connect(self._on_end_reached, Qt.ConnectionType.QueuedConnection)
self.video.mpvError.connect(self.playerError, 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 = QTimer(self)
self._volume_apply_timer.setSingleShot(True) self._volume_apply_timer.setSingleShot(True)
@@ -318,6 +674,36 @@ class PlayerPanel(QWidget):
self._stall_retries = 0 self._stall_retries = 0
self._playback_started = False 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 # ------------------------------------------------------------ public API
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None: def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
@@ -376,6 +762,132 @@ class PlayerPanel(QWidget):
except Exception: except Exception:
pass 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]: def current_entry(self) -> Dict[str, Any]:
return dict(self._current_entry) return dict(self._current_entry)
@@ -411,8 +923,40 @@ class PlayerPanel(QWidget):
@Slot(bool) @Slot(bool)
def _on_paused_changed(self, paused: bool) -> None: def _on_paused_changed(self, paused: bool) -> None:
icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause self.play_btn.setIcon(icons.icon("play" if paused else "pause", theme.ICON_ON_ACCENT))
self.play_btn.setIcon(self.style().standardIcon(icon)) 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) @Slot(str)
def _on_end_reached(self, reason: str) -> None: def _on_end_reached(self, reason: str) -> None:
@@ -439,8 +983,10 @@ class PlayerPanel(QWidget):
if mpv_inst is None: if mpv_inst is None:
return return
mpv_inst["ytdl-format"] = _ytdl_format_for(height) mpv_inst["ytdl-format"] = _ytdl_format_for(height)
# Reload the current item at the new quality, keeping position # Reload the current item at the new quality, keeping position. Only
if self._current_entry: # 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() pos = self.current_position()
self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0) self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0)
@@ -472,32 +1018,97 @@ class PlayerPanel(QWidget):
except Exception: except Exception:
pass pass
def toggle_fullscreen(self) -> None: # ---------------------------------------------------------- mouse/idle
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: def _on_wheel(self, notches: int, ctrl_held: bool) -> None:
if event.key() == Qt.Key.Key_Escape and self._fullscreen_holder is not None: if ctrl_held:
self.toggle_fullscreen() self.seek_relative(notches * SEEK_MEDIUM)
elif event.key() == Qt.Key.Key_Space:
self.toggle_pause()
elif event.key() == Qt.Key.Key_F:
self.toggle_fullscreen()
else: else:
super().keyPressEvent(event) 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): class PlayerUnavailablePanel(QWidget):
+7
View File
@@ -17,3 +17,10 @@ class AppRouter(QObject):
downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed
openChannel = Signal(str) # open a channel URL in the Browse tab openChannel = Signal(str) # open a channel URL in the Browse tab
openPlaylist = Signal(str) # open a playlist URL in the Browse tab openPlaylist = Signal(str) # open a playlist URL in the Browse tab
# Cookie settings were applied. YtdlpClient re-reads config per call so it
# needs no telling, but the account button, the Feed's mode availability
# and the live mpv handle all do -- otherwise signing in only takes effect
# after a restart.
cookiesChanged = Signal()
# Somewhere in the UI asked for the cookie login dialog.
cookieSetupRequested = Signal()
+1
View File
@@ -33,6 +33,7 @@ class SearchPage(QWidget):
bar.addWidget(self.query_input, stretch=1) bar.addWidget(self.query_input, stretch=1)
self.search_btn = QPushButton(_("search.button")) self.search_btn = QPushButton(_("search.button"))
self.search_btn.setToolTip(_("search.button_tooltip"))
self.search_btn.clicked.connect(self.start_search) self.search_btn.clicked.connect(self.start_search)
self.search_btn.setMinimumHeight(38) self.search_btn.setMinimumHeight(38)
bar.addWidget(self.search_btn) bar.addWidget(self.search_btn)
+47 -1
View File
@@ -34,6 +34,9 @@ class WatchPage(QWidget):
super().__init__(parent) super().__init__(parent)
self._router = router self._router = router
self._queue: List[Dict[str, Any]] = [] self._queue: List[Dict[str, Any]] = []
# What has already played this session, so "previous" has somewhere
# to go. Not persisted: it is session history, not the queue.
self._played: List[Dict[str, Any]] = []
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8) layout.setContentsMargins(8, 8, 8, 8)
@@ -44,7 +47,9 @@ class WatchPage(QWidget):
self.player = create_player_panel(self) self.player = create_player_panel(self)
splitter.addWidget(self.player) splitter.addWidget(self.player)
queue_panel = QWidget(self) # Kept as an attribute: FullscreenController hides it rather than
# reparenting the player, which is what used to lose the GL context.
self.queue_panel = queue_panel = QWidget(self)
queue_layout = QVBoxLayout(queue_panel) queue_layout = QVBoxLayout(queue_panel)
queue_layout.setContentsMargins(4, 0, 0, 0) queue_layout.setContentsMargins(4, 0, 0, 0)
@@ -79,6 +84,9 @@ class WatchPage(QWidget):
self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS) self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS)
self._position_timer.timeout.connect(self._save_position) self._position_timer.timeout.connect(self._save_position)
self.player.nowPlayingChanged.connect(self._on_now_playing) self.player.nowPlayingChanged.connect(self._on_now_playing)
# The panel asks; the queue lives here, so the queue answers.
self.player.nextRequested.connect(self.play_next)
self.player.previousRequested.connect(self.play_previous)
self._restore_queue() self._restore_queue()
@@ -112,6 +120,39 @@ class WatchPage(QWidget):
def queue_entries(self) -> List[Dict[str, Any]]: def queue_entries(self) -> List[Dict[str, Any]]:
return [self.queue_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.queue_list.count())] return [self.queue_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.queue_list.count())]
def play_next(self) -> None:
"""Next in the queue. Bound to N and the player's next button."""
self._play_next_from_queue()
def play_previous(self) -> None:
"""
Back to what was playing before.
Restarts the current item first if it is more than a few seconds in,
which is what every other player does with a "previous" press.
"""
if not isinstance(self.player, PlayerPanel):
return
if self.player.current_position() > 5.0:
self.player.seek_absolute(0.0)
return
# _played ends with what is playing now, so going back needs two.
if len(self._played) < 2:
return
current = self._played.pop()
previous = self._played[-1]
# Put the current item back at the head of the queue rather than
# dropping it on the floor.
if current:
item = QListWidgetItem(current.get("title") or current.get("url") or "?")
item.setData(Qt.ItemDataRole.UserRole, dict(current))
self.queue_list.insertItem(0, item)
self._queue.insert(0, dict(current))
self._persist_queue()
# play_entry re-pushes `previous`, so drop it here to avoid a double.
self._played.pop()
self.play_entry(previous)
# --------------------------------------------------------------- internal # --------------------------------------------------------------- internal
def _play_next_from_queue(self) -> None: def _play_next_from_queue(self) -> None:
@@ -146,6 +187,11 @@ class WatchPage(QWidget):
def _on_now_playing(self, entry: Dict[str, Any]) -> None: def _on_now_playing(self, entry: Dict[str, Any]) -> None:
LibraryManager.upsert_watch(entry) LibraryManager.upsert_watch(entry)
# Record what we were on before this, so "previous" can return to it.
# The retry path replays the same entry; don't stack duplicates.
if self._played[-1:] != [entry] and entry:
self._played.append(dict(entry))
del self._played[:-50]
self._duration = 0.0 self._duration = 0.0
self._position_timer.start() self._position_timer.start()
+169
View File
@@ -0,0 +1,169 @@
"""
Icons
=====
The app had no icon system. Transport buttons used
`QStyle.standardIcon(SP_MediaPlay)`, which returns the platform style's dark
monochrome glyph -- painted onto the app's saturated red buttons, at a fixed
36px with no text and no tooltip, that reads as a black or empty square. On
styles that return a null icon for some standard pixmaps it *was* an empty
square. Everything else called an "icon" was an emoji baked into the English
translation file, which renders as tofu wherever the emoji font is missing.
Approach: a small set of hand-drawn SVGs rendered through QtSvg (part of
PySide6-Essentials, so no new dependency), recoloured at load. Qt stylesheets
cannot recolour a QIcon, which is the whole reason the old icons were
unreadable -- so the colour is an argument here.
The sources live in this module rather than as asset files on purpose: no
package-data to keep in sync, no path resolution, and nothing to go missing
from a wheel or a frozen build.
Drawing conventions: 24x24 viewBox, 2px round strokes, `%COLOR%` wherever the
colour goes. Shapes that must read as solid at 16px (the play triangle) carry
their own fill.
"""
from functools import lru_cache
from typing import Dict, Optional
from PySide6.QtCore import QByteArray, QRectF, Qt
from PySide6.QtGui import QIcon, QPainter, QPixmap
from PySide6.QtSvg import QSvgRenderer
from ..utils.ytsage_logger import logger
#: Default stroke colour. Overridden per call; kept in step with ytsage_theme.
DEFAULT_COLOR = "#e8eaed"
_SVG: Dict[str, str] = {
# --- transport -------------------------------------------------------
"play": '<polygon points="7 4 20 12 7 20" fill="%COLOR%" stroke-linejoin="round"/>',
"pause": '<rect x="6" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>'
'<rect x="14" y="4" width="4" height="16" rx="1" fill="%COLOR%"/>',
"stop": '<rect x="5" y="5" width="14" height="14" rx="2" fill="%COLOR%"/>',
"skip-back": '<polygon points="19 5 9 12 19 19" fill="%COLOR%" stroke-linejoin="round"/>'
'<line x1="6" y1="5" x2="6" y2="19"/>',
"skip-forward": '<polygon points="5 5 15 12 5 19" fill="%COLOR%" stroke-linejoin="round"/>'
'<line x1="18" y1="5" x2="18" y2="19"/>',
"rewind-10": '<path d="M11 20a8 8 0 1 0-8-8"/><polyline points="3 8 3 12 7 12"/>'
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
' text-anchor="middle" font-family="sans-serif">10</text>',
"forward-10": '<path d="M13 20a8 8 0 1 1 8-8"/><polyline points="21 8 21 12 17 12"/>'
'<text x="12" y="16" font-size="8" fill="%COLOR%" stroke="none"'
' text-anchor="middle" font-family="sans-serif">10</text>',
# --- audio -----------------------------------------------------------
"volume-high": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/>',
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
'<path d="M15.5 8.5a5 5 0 0 1 0 7"/>',
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19" fill="%COLOR%" stroke-linejoin="round"/>'
'<line x1="16" y1="9" x2="22" y2="15"/><line x1="22" y1="9" x2="16" y2="15"/>',
"captions": '<rect x="2" y="5" width="20" height="14" rx="3"/>'
'<path d="M10 10.2a2.6 2.6 0 1 0 0 3.6"/><path d="M17.5 10.2a2.6 2.6 0 1 0 0 3.6"/>',
# --- window ----------------------------------------------------------
"maximize": '<path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/>'
'<path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/>',
"minimize": '<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>'
'<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>',
# --- tabs / navigation -----------------------------------------------
"rss": '<path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/>'
'<circle cx="5" cy="19" r="1.6" fill="%COLOR%" stroke="none"/>',
"search": '<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.6" y2="16.6"/>',
"library": '<line x1="4" y1="4" x2="4" y2="20"/><line x1="9" y1="6" x2="9" y2="20"/>'
'<path d="M14 6.5l4.5 13"/>',
"download": '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>'
'<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
"user": '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
"user-check": '<path d="M15 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>'
'<polyline points="16 11 18 13 22 9"/>',
# --- actions ---------------------------------------------------------
"queue": '<line x1="3" y1="6" x2="16" y2="6"/><line x1="3" y1="12" x2="11" y2="12"/>'
'<line x1="3" y1="18" x2="11" y2="18"/><line x1="18" y1="9" x2="18" y2="15"/>'
'<line x1="21" y1="12" x2="15" y2="12"/>',
"refresh": '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><polyline points="21 3 21 9 15 9"/>',
"folder-open": '<path d="M4 20a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4.2a2 2 0 0 1 1.6.8l1 1.4a2 2 0 0 0 1.6.8H18a2 2 0 0 1 2 2v1"/>'
'<path d="M4 20l2.2-7a2 2 0 0 1 1.9-1.4h12a1.6 1.6 0 0 1 1.55 2l-1.7 5.4A2 2 0 0 1 18 20z"/>',
"x": '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
"check": '<polyline points="20 6 9 17 4 12"/>',
"plus": '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
"trash": '<polyline points="3 6 21 6"/>'
'<path d="M19 6v13a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>'
'<path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>',
"settings": '<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>'
'<circle cx="10" cy="7" r="2.4" fill="%COLOR%" stroke="none"/>'
'<circle cx="15" cy="17" r="2.4" fill="%COLOR%" stroke="none"/>',
"info": '<circle cx="12" cy="12" r="9"/><line x1="12" y1="11" x2="12" y2="16"/>'
'<circle cx="12" cy="7.8" r="1.1" fill="%COLOR%" stroke="none"/>',
"clock": '<circle cx="12" cy="12" r="9"/><polyline points="12 6.8 12 12 15.5 14"/>',
"scissors": '<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
'<line x1="20" y1="4" x2="8.1" y2="15.9"/><line x1="14.5" y1="14.5" x2="20" y2="20"/>'
'<line x1="8.1" y1="8.1" x2="12" y2="12"/>',
"external-link": '<path d="M14 3h7v7"/><line x1="10" y1="14" x2="21" y2="3"/>'
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
"chevron-down": '<polyline points="6 9 12 15 18 9"/>',
"chevron-right": '<polyline points="9 6 15 12 9 18"/>',
"clipboard": '<rect x="8" y="3" width="8" height="4" rx="1"/>'
'<path d="M16 5h2a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2"/>',
"play-circle": '<circle cx="12" cy="12" r="9"/><polygon points="10 8.5 16 12 10 15.5" fill="%COLOR%" stroke="none"/>',
}
_DOC = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" '
'fill="none" stroke="%COLOR%" stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round">%BODY%</svg>'
)
def available() -> list:
"""Every icon name this module can draw. Useful when adding call sites."""
return sorted(_SVG)
def _document(name: str, color: str) -> Optional[bytes]:
body = _SVG.get(name)
if body is None:
return None
return _DOC.replace("%BODY%", body).replace("%COLOR%", color).encode("utf-8")
@lru_cache(maxsize=512)
def pixmap(name: str, color: str = DEFAULT_COLOR, size: int = 20, dpr: float = 1.0) -> QPixmap:
"""One rendered pixmap. A missing name yields a transparent one, never an error."""
px = QPixmap(max(1, int(size * dpr)), max(1, int(size * dpr)))
px.setDevicePixelRatio(dpr)
px.fill(Qt.GlobalColor.transparent)
document = _document(name, color)
if document is None:
# A typo in a call site should show a gap, not take a dialog down.
logger.debug(f"Unknown icon name: {name!r}")
return px
renderer = QSvgRenderer(QByteArray(document))
if not renderer.isValid():
logger.debug(f"Icon {name!r} failed to parse")
return px
painter = QPainter(px)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
try:
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
finally:
painter.end()
return px
@lru_cache(maxsize=512)
def icon(name: str, color: str = DEFAULT_COLOR, size: int = 20, disabled_color: str = "#6b7075") -> QIcon:
"""
A QIcon with its own Normal and Disabled artwork.
Both 1x and 2x are added so the icon stays sharp on a HiDPI screen without
Qt upscaling a small bitmap.
"""
result = QIcon()
for dpr in (1.0, 2.0):
result.addPixmap(pixmap(name, color, size, dpr), QIcon.Mode.Normal, QIcon.State.Off)
result.addPixmap(pixmap(name, disabled_color, size, dpr), QIcon.Mode.Disabled, QIcon.State.Off)
return result
+140
View File
@@ -0,0 +1,140 @@
"""
Fullscreen without reparenting the video widget
===============================================
The obvious implementation -- take the player out of its layout, make it a
top-level window, `showFullScreen()` -- is what SageTube did, and it is a
reliable way to crash. Reparenting a widget into a new top-level destroys and
recreates the QOpenGLContext of every QOpenGLWidget beneath it, and each
recreation is another chance to lose the libmpv render context. A single
fullscreen toggle did it twice.
So nothing is reparented here. The **main window** goes fullscreen and the
chrome around the video is hidden. QMainWindow.showFullScreen() reuses the
same QWindow and native surface -- on Wayland it is an
`xdg_toplevel.set_fullscreen` -- so the GL context is untouched and the video
never blinks.
The same mechanism gives "cinema mode" (hide the chrome, stay windowed) for
free, and it gives Escape one unambiguous owner.
"""
from typing import Optional
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import QMainWindow, QWidget
from ..utils.ytsage_logger import logger
class FullscreenController(QObject):
"""
Drives fullscreen for the Watch page.
Owns no widgets and destroys nothing; it only toggles visibility and the
main window's state, and can always put things back.
"""
changed = Signal(bool)
def __init__(
self,
window: QMainWindow,
tabs: QWidget,
watch_page: QWidget,
parent: Optional[QObject] = None,
) -> None:
super().__init__(parent)
self._window = window
self._tabs = tabs
self._watch_page = watch_page
self._active = False
self._prev_window_state: Optional[Qt.WindowState] = None
self._prev_margins = None
# ------------------------------------------------------------------ state
def is_active(self) -> bool:
return self._active
def toggle(self) -> None:
self.exit() if self._active else self.enter()
# ------------------------------------------------------------ transitions
def enter(self) -> None:
if self._active:
return
try:
# Fullscreening while another tab is showing would present an
# empty screen, so make sure the video is the visible page first.
self._show_watch_tab()
self._prev_window_state = self._window.windowState()
tab_bar = getattr(self._tabs, "tab_bar", None)
if tab_bar is not None:
tab_bar.hide()
corner = getattr(self._tabs, "corner_widget", None)
if corner is not None:
corner.hide()
queue_panel = getattr(self._watch_page, "queue_panel", None)
if queue_panel is not None:
queue_panel.hide()
layout = self._watch_page.layout()
if layout is not None:
self._prev_margins = layout.contentsMargins()
layout.setContentsMargins(0, 0, 0, 0)
self._window.showFullScreen()
self._active = True
self.changed.emit(True)
except Exception as e:
logger.exception(f"Entering fullscreen failed: {e}")
# Never leave the UI half-hidden with no way back.
self.exit()
def exit(self) -> None:
if not self._active and self._prev_window_state is None:
return
try:
tab_bar = getattr(self._tabs, "tab_bar", None)
if tab_bar is not None:
tab_bar.show()
corner = getattr(self._tabs, "corner_widget", None)
if corner is not None:
corner.show()
queue_panel = getattr(self._watch_page, "queue_panel", None)
if queue_panel is not None:
queue_panel.show()
layout = self._watch_page.layout()
if layout is not None and self._prev_margins is not None:
layout.setContentsMargins(self._prev_margins)
if self._prev_window_state is not None:
self._window.setWindowState(self._prev_window_state)
else:
self._window.showNormal()
except Exception as e:
logger.exception(f"Leaving fullscreen failed: {e}")
finally:
self._prev_window_state = None
self._prev_margins = None
self._active = False
self.changed.emit(False)
# ---------------------------------------------------------------- helpers
def _show_watch_tab(self) -> None:
stack = getattr(self._tabs, "stack", None)
set_index = getattr(self._tabs, "set_current_index", None)
if stack is None or set_index is None:
return
for i in range(stack.count()):
if stack.widget(i) is self._watch_page:
set_index(i)
return
+154
View File
@@ -0,0 +1,154 @@
"""
Player keyboard and mouse bindings
==================================
The player had one `keyPressEvent` handling Space, F and Escape -- and it never
ran. Nothing in the player set a focus policy, so focus always landed on a
child button, slider or combo box, which swallowed Space and the arrow keys.
There were no mouse handlers at all and no `QShortcut` anywhere in the
application.
Why Qt handlers rather than mpv's own bindings
----------------------------------------------
`input_default_bindings` stays off. With `vo=libmpv` there is no mpv-owned
window, so mpv's input layer receives nothing -- it is fed by the video
output's windowing backend, which does not exist in this embedding. Turning
default bindings on would only matter if key events were hand-forwarded with
`keypress`, which needs a full Qt-to-mpv key-name table, hands mpv ownership of
the OSD and OSC (both disabled here), and lets `q` quit the core out from under
the Qt UI.
Routing through Qt also means one action table drives the shortcuts, the
context menu and the buttons, and the labels stay translatable.
Focus, and why the shortcuts are scoped
---------------------------------------
Bindings are `QShortcut`s with `WidgetWithChildrenShortcut` context on the
panel. That fires when the panel *or any descendant* has focus, so it works
whichever child holds it -- and it does not steal keys from the Search box or
the Downloads URL field on other tabs, which an application-wide shortcut
would.
"""
from dataclasses import dataclass, field
from typing import Callable, List, Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import QMenu, QWidget
from ..utils.ytsage_localization import _
#: Seek steps, in seconds.
SEEK_SMALL = 1.0
SEEK_MEDIUM = 5.0
SEEK_LARGE = 10.0
VOLUME_STEP = 5
SPEED_STEP = 0.25
@dataclass(frozen=True)
class Action:
"""One thing the player can do, and every way to ask for it."""
ident: str
keys: List[str]
label_key: str
method: str
icon: Optional[str] = None
#: Whether it belongs in the right-click menu.
in_menu: bool = True
args: tuple = field(default_factory=tuple)
ACTIONS: List[Action] = [
Action("play_pause", ["Space", "K", "Media Play"], "player.act_play_pause", "toggle_pause", "play"),
Action("seek_back_5", ["Left"], "player.act_seek_back_5", "seek_relative", None, False, (-SEEK_MEDIUM,)),
Action("seek_fwd_5", ["Right"], "player.act_seek_fwd_5", "seek_relative", None, False, (SEEK_MEDIUM,)),
Action("seek_back_10", ["J"], "player.act_seek_back_10", "seek_relative", None, True, (-SEEK_LARGE,)),
Action("seek_fwd_10", ["L"], "player.act_seek_fwd_10", "seek_relative", None, True, (SEEK_LARGE,)),
Action("seek_back_1", ["Shift+Left"], "player.act_seek_back_1", "seek_relative", None, False, (-SEEK_SMALL,)),
Action("seek_fwd_1", ["Shift+Right"], "player.act_seek_fwd_1", "seek_relative", None, False, (SEEK_SMALL,)),
Action("frame_back", [","], "player.act_frame_back", "frame_step", None, False, (-1,)),
Action("frame_fwd", ["."], "player.act_frame_fwd", "frame_step", None, False, (1,)),
Action("vol_up", ["Up"], "player.act_vol_up", "nudge_volume", None, False, (VOLUME_STEP,)),
Action("vol_down", ["Down"], "player.act_vol_down", "nudge_volume", None, False, (-VOLUME_STEP,)),
Action("mute", ["M"], "player.act_mute", "toggle_mute", "volume-mute"),
Action("speed_up", ["]"], "player.act_speed_up", "nudge_speed", None, False, (SPEED_STEP,)),
Action("speed_down", ["["], "player.act_speed_down", "nudge_speed", None, False, (-SPEED_STEP,)),
Action("speed_reset", ["Backspace"], "player.act_speed_reset", "reset_speed", None, True),
Action("subtitles", ["C"], "player.act_subtitles", "cycle_subtitles", "captions"),
Action("fullscreen", ["F"], "player.act_fullscreen", "toggle_fullscreen", "maximize"),
Action("leave_fullscreen", ["Escape"], "player.act_leave_fullscreen", "exit_fullscreen", None, False),
Action("next", ["N", "Ctrl+Right", "Media Next"], "player.act_next", "request_next", "skip-forward"),
Action("previous", ["P", "Ctrl+Left", "Media Previous"], "player.act_previous", "request_previous", "skip-back"),
Action("start", ["Home"], "player.act_start", "seek_absolute", None, False, (0.0,)),
Action("end", ["End"], "player.act_end", "seek_to_end", None, False),
]
#: 0-9 jump to that tenth of the video, as YouTube does.
PERCENT_KEYS = [(str(n), n / 10.0) for n in range(10)]
def install_player_shortcuts(panel: QWidget) -> List[QShortcut]:
"""
Bind every action to the panel.
Returns the shortcuts so the caller can keep them alive -- a QShortcut
with no reference is collected and silently stops working.
"""
shortcuts: List[QShortcut] = []
def bind(sequence: str, handler: Callable) -> None:
shortcut = QShortcut(QKeySequence(sequence), panel)
shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
shortcut.activated.connect(handler)
shortcuts.append(shortcut)
for action in ACTIONS:
method = getattr(panel, action.method, None)
if method is None:
continue
for key in action.keys:
bind(key, (lambda m=method, a=action.args: m(*a)))
seek_percent = getattr(panel, "seek_percent", None)
if seek_percent is not None:
for key, fraction in PERCENT_KEYS:
bind(key, (lambda f=fraction: seek_percent(f)))
return shortcuts
def build_context_menu(panel: QWidget) -> QMenu:
"""The right-click menu, from the same table as the shortcuts."""
from . import ytsage_icons as icons
from . import ytsage_theme as theme
menu = QMenu(panel)
for action in ACTIONS:
if not action.in_menu:
continue
method = getattr(panel, action.method, None)
if method is None:
continue
label = _(action.label_key)
if action.keys:
label = f"{label}\t{action.keys[0]}"
entry = menu.addAction(label)
if action.icon:
entry.setIcon(icons.icon(action.icon, theme.ICON))
entry.triggered.connect(lambda _checked=False, m=method, a=action.args: m(*a))
menu.addSeparator()
copy_url = getattr(panel, "copy_video_url", None)
if copy_url is not None:
entry = menu.addAction(_("player.act_copy_url"))
entry.setIcon(icons.icon("clipboard", theme.ICON))
entry.triggered.connect(lambda _checked=False: copy_url())
open_browser = getattr(panel, "open_in_browser", None)
if open_browser is not None:
entry = menu.addAction(_("player.act_open_browser"))
entry.setIcon(icons.icon("external-link", theme.ICON))
entry.triggered.connect(lambda _checked=False: open_browser())
return menu
+68 -6
View File
@@ -1,8 +1,10 @@
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, Signal
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QGraphicsOpacityEffect, QGraphicsOpacityEffect,
QHBoxLayout,
QLabel, QLabel,
QStackedWidget, QStackedWidget,
QTabBar, QTabBar,
@@ -19,6 +21,13 @@ class FadingStackedWidget(QStackedWidget):
self.fade_duration = 300 self.fade_duration = 300
self.fade_easing = QEasingCurve.Type.OutQuad self.fade_easing = QEasingCurve.Type.OutQuad
@staticmethod
def _contains_gl(widget):
"""Whether an OpenGL surface lives anywhere under this page."""
if widget is None:
return False
return isinstance(widget, QOpenGLWidget) or widget.findChild(QOpenGLWidget) is not None
def setCurrentIndex(self, index): def setCurrentIndex(self, index):
curr_index = self.currentIndex() curr_index = self.currentIndex()
if index == curr_index: if index == curr_index:
@@ -32,6 +41,15 @@ class FadingStackedWidget(QStackedWidget):
super().setCurrentIndex(index) super().setCurrentIndex(index)
return return
# Never fade a page containing an OpenGL surface. grab() on one
# returns a black rectangle -- so the "fade" was a black slab sliding
# over the new tab -- and forcing a framebuffer readback plus a
# QGraphicsEffect over that subtree is a way to lose the GL context,
# which for the embedded mpv player means a crash.
if self._contains_gl(widget) or self._contains_gl(curr_widget):
super().setCurrentIndex(index)
return
# 1. Capture the current view (the "old" tab) # 1. Capture the current view (the "old" tab)
# Use grab() for simplicity and reliability in PySide6 # Use grab() for simplicity and reliability in PySide6
pixmap = self.grab() pixmap = self.grab()
@@ -77,6 +95,11 @@ class SmoothTabWidget(QWidget):
A unified Widget that behaves like a QTabWidget but uses smooth fading transitions. A unified Widget that behaves like a QTabWidget but uses smooth fading transitions.
Includes a QTabBar and a FadingStackedWidget. Includes a QTabBar and a FadingStackedWidget.
""" """
#: Emitted after the visible page has actually changed. The QTabBar's own
#: currentChanged is not usable for this: it fires before the stack has
#: switched, and it re-enters through set_current_index.
currentChanged = Signal(int)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -85,11 +108,24 @@ class SmoothTabWidget(QWidget):
self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0) self.layout.setSpacing(0)
# Tab Bar # Tab bar, plus a right-aligned slot for a corner widget. Wrapped in a
self.tab_bar = QTabBar(self) # row so something (the account button) can sit opposite the tabs the
# way QTabWidget::setCornerWidget would allow.
self.tab_row = QWidget(self)
tab_row_layout = QHBoxLayout(self.tab_row)
tab_row_layout.setContentsMargins(0, 0, 0, 0)
tab_row_layout.setSpacing(0)
self.tab_bar = QTabBar(self.tab_row)
self.tab_bar.setDrawBase(False) # We draw border on content instead self.tab_bar.setDrawBase(False) # We draw border on content instead
self.tab_bar.currentChanged.connect(self.set_current_index) self.tab_bar.currentChanged.connect(self.set_current_index)
self.layout.addWidget(self.tab_bar) tab_row_layout.addWidget(self.tab_bar)
tab_row_layout.addStretch(1)
self.corner_widget = None
self._switching = False
self._tab_row_layout = tab_row_layout
self.layout.addWidget(self.tab_row)
# Content Area (Frame) - Mimics QTabWidget::pane # Content Area (Frame) - Mimics QTabWidget::pane
self.content_frame = QFrame(self) self.content_frame = QFrame(self)
@@ -106,15 +142,41 @@ class SmoothTabWidget(QWidget):
self.layout.addWidget(self.content_frame) self.layout.addWidget(self.content_frame)
def addTab(self, widget, label): def addTab(self, widget, label, icon=None):
"""Add a tab with the given widget and label.""" """Add a tab with the given widget, label and optional icon."""
self.stack.addWidget(widget) self.stack.addWidget(widget)
if icon is not None:
self.tab_bar.addTab(icon, label)
else:
self.tab_bar.addTab(label) self.tab_bar.addTab(label)
def setCornerWidget(self, widget):
"""Place a widget at the right-hand end of the tab row."""
if self.corner_widget is not None:
self.corner_widget.setParent(None)
self.corner_widget = widget
if widget is not None:
widget.setParent(self.tab_row)
self._tab_row_layout.addWidget(widget)
def set_current_index(self, index): def set_current_index(self, index):
"""Slot to handle tab bar clicks.""" """Slot to handle tab bar clicks."""
if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index:
return
# setCurrentIndex on the bar emits its own currentChanged, which is
# connected straight back here -- and at that moment the stack has not
# moved yet, so a plain index comparison does not catch the re-entry.
# Without this flag every switch emitted currentChanged twice and each
# page's activation hook ran twice.
if self._switching:
return
self._switching = True
try:
self.tab_bar.setCurrentIndex(index) self.tab_bar.setCurrentIndex(index)
self.stack.setCurrentIndex(index) self.stack.setCurrentIndex(index)
finally:
self._switching = False
self.currentChanged.emit(index)
def currentWidget(self): def currentWidget(self):
return self.stack.currentWidget() return self.stack.currentWidget()
+11 -5
View File
@@ -224,20 +224,26 @@ class StyleSheet:
padding: 5px; padding: 5px;
margin-left: 20px; margin-left: 20px;
} }
/* Square, not round: a fully-rounded indicator reads as a radio
button, i.e. "one of these", when these are independent
on/off options. */
QCheckBox::indicator { QCheckBox::indicator {
width: 18px; width: 16px;
height: 18px; height: 16px;
border-radius: 9px; border-radius: 4px;
} }
QCheckBox::indicator:unchecked { QCheckBox::indicator:unchecked {
border: 2px solid #666666; border: 2px solid #666666;
background: #1d1e22; background: #1d1e22;
border-radius: 9px; border-radius: 4px;
}
QCheckBox::indicator:unchecked:hover {
border-color: #9aa0a6;
} }
QCheckBox::indicator:checked { QCheckBox::indicator:checked {
border: 2px solid #c90000; border: 2px solid #c90000;
background: #c90000; background: #c90000;
border-radius: 9px; border-radius: 4px;
} }
QCheckBox:disabled { color: #888888; } QCheckBox:disabled { color: #888888; }
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; } QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
+327
View File
@@ -0,0 +1,327 @@
"""
Theme tokens and the stylesheet rules the app never had
=======================================================
`StyleSheet.MAIN` (upstream's, in ytsage_stylesheet.py) styles the window,
line edits, buttons, tables, progress bars and vertical scrollbars -- and
nothing else. Everything it misses falls through to `QWidget { background:
#15181b; color: #ffffff }` plus the platform style, which is why the app looked
half-finished: the main tab bar was drawn by Fusion from the system palette
with white text forced onto it, combo boxes and sliders were native, tooltips
came out in the system's light style on a black app, and a native light
horizontal scrollbar appeared under any wide view.
Notably `SmoothTabWidget` names its content frame `tabContent` with the comment
"We draw border on content instead" -- and the only `QFrame#tabContent` rules
in the codebase were inside two dialogs. The main window's tab bar got none of
it.
This module holds the colour tokens and `EXTRA_QSS`, appended to
`StyleSheet.MAIN` at the single application site. Keeping it separate means one
changed line in an upstream-owned file, so the next merge from YTSage does not
fight over it.
The two dialogs that define their own tab rules keep them. Unifying would cost
more in merge conflicts than the duplication does.
"""
from ..utils.ytsage_logger import logger
# --- colour tokens -------------------------------------------------------
# Names, not hexes, at the call sites. These match what StyleSheet.MAIN
# already uses so the two halves agree.
BG = "#15181b" # window
SURFACE = "#1b2021" # panels, inputs, list backgrounds
SURFACE_ALT = "#1d2124" # menus, tooltips, elevated surfaces
SURFACE_HOVER = "#252a2d"
BORDER = "#2a2d2e"
BORDER_STRONG = "#3d3d3d"
TEXT = "#ffffff"
TEXT_MUTED = "#9aa0a6"
TEXT_DISABLED = "#6b7075"
ACCENT = "#c90000"
ACCENT_HOVER = "#a50000"
ACCENT_PRESSED = "#800000"
FOCUS = "#ff6b6b"
#: Default icon colour: near-white, deliberately not pure #ffffff so it does
#: not out-glare the text beside it.
ICON = "#e8eaed"
#: Icons sitting on an accent-coloured button.
ICON_ON_ACCENT = "#ffffff"
def ensure_ui_assets() -> dict:
"""
Write the few bitmaps Qt stylesheets can only reference as files.
QSS cannot draw a shape: `::down-arrow` and `::indicator` need an actual
`image:`. Everything else here is drawn with borders and radii, but a
caret and a tick are not expressible that way -- restyling a combo box
without supplying one leaves it with a blank square where its arrow was.
Rendered from the same SVG set as every other icon and cached next to the
thumbnails, so nothing extra ships and nothing is fetched.
"""
from . import ytsage_icons as icons
from ..utils.ytsage_constants import APP_DATA_DIR
out_dir = APP_DATA_DIR / "ui"
# Rendered larger than the box they are drawn into (see the QSS below):
# a 24-unit viewBox squeezed straight into 12px leaves a 1px stroke that
# all but disappears on this background.
wanted = {
"chevron": ("chevron-down", TEXT_MUTED, 14),
"chevron_disabled": ("chevron-down", TEXT_DISABLED, 14),
"check": ("check", TEXT, 12),
}
paths = {}
try:
out_dir.mkdir(parents=True, exist_ok=True)
for key, (name, color, size) in wanted.items():
target = out_dir / f"{key}.png"
if not target.exists():
# dpr 1.0 and the final pixel size: Qt draws a QSS `image:` at
# its natural size, so anything else is scaled or clipped.
icons.pixmap(name, color, size, 1.0).save(str(target), "PNG")
# QSS wants forward slashes even on Windows.
paths[key] = str(target).replace("\\", "/")
except Exception as e: # pragma: no cover - cosmetic only
logger.debug(f"Could not write UI assets, falling back to plain styling: {e}")
return {}
return paths
def build_extra_qss() -> str:
"""EXTRA_QSS plus the rules that need generated images, when available."""
qss = EXTRA_QSS
assets = ensure_ui_assets()
if not assets:
return qss
return qss + f"""
QComboBox::down-arrow {{
image: url("{assets['chevron']}");
border: none;
}}
QComboBox::down-arrow:disabled {{ image: url("{assets['chevron_disabled']}"); }}
/* Upstream styles checkboxes as filled circles (border-radius: 9px), which
reads as a radio button -- a control that means "one of these" rather than
"on or off". Square them off and show an actual tick. Widget-level
stylesheets win over this one, so StyleSheet.CHECKBOX is corrected at
source; these rules cover every checkbox that does not use it. */
QCheckBox::indicator {{
width: 16px;
height: 16px;
border-radius: 4px;
border: 2px solid {BORDER_STRONG};
background-color: {SURFACE};
}}
QCheckBox::indicator:hover {{ border-color: {TEXT_MUTED}; }}
QCheckBox::indicator:checked {{
border-color: {ACCENT};
background-color: {ACCENT};
image: url("{assets['check']}");
}}
QCheckBox::indicator:checked:hover {{ border-color: {FOCUS}; }}
QCheckBox::indicator:disabled {{ border-color: {BORDER}; background-color: {BG}; }}
"""
EXTRA_QSS = f"""
/* ---- main tab bar (SmoothTabWidget) --------------------------------- */
QTabBar {{
background-color: {BG};
border: none;
}}
QTabBar::tab {{
background-color: transparent;
color: {TEXT_MUTED};
padding: 9px 18px;
margin-right: 2px;
border: none;
border-bottom: 2px solid transparent;
font-size: 13px;
}}
QTabBar::tab:hover:!selected {{
color: {TEXT};
background-color: {SURFACE};
}}
QTabBar::tab:selected {{
color: {TEXT};
background-color: {SURFACE};
border-bottom: 2px solid {ACCENT};
}}
QTabBar::tab:disabled {{
color: {TEXT_DISABLED};
}}
QFrame#tabContent {{
background-color: {BG};
border: none;
border-top: 1px solid {BORDER};
}}
/* ---- combo boxes ---------------------------------------------------- */
QComboBox {{
background-color: {SURFACE};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 5px 10px;
min-height: 18px;
}}
QComboBox:hover {{ border-color: {BORDER_STRONG}; }}
QComboBox:focus {{ border-color: {FOCUS}; }}
QComboBox:disabled {{ color: {TEXT_DISABLED}; background-color: {BG}; }}
QComboBox::drop-down {{
border: none;
width: 22px;
subcontrol-origin: padding;
subcontrol-position: center right;
}}
/* ::down-arrow is deliberately not styled here -- QSS cannot draw a caret,
and setting only a border leaves a small square where the arrow was.
build_extra_qss() supplies a real image; without it Qt's native arrow is
the better fallback. */
QComboBox QAbstractItemView {{
background-color: {SURFACE_ALT};
color: {TEXT};
border: 1px solid {BORDER};
selection-background-color: {ACCENT};
selection-color: {TEXT};
outline: none;
padding: 2px;
}}
/* ---- lists ---------------------------------------------------------- */
QListWidget, QListView {{
background-color: {SURFACE};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
outline: none;
}}
QListWidget::item, QListView::item {{
padding: 6px 8px;
border-radius: 3px;
}}
QListWidget::item:hover, QListView::item:hover {{ background-color: {SURFACE_HOVER}; }}
QListWidget::item:selected, QListView::item:selected {{
background-color: {ACCENT};
color: {TEXT};
}}
/* ---- menus ---------------------------------------------------------- */
QMenu {{
background-color: {SURFACE_ALT};
color: {TEXT};
border: 1px solid {BORDER};
padding: 4px;
}}
QMenu::item {{ padding: 6px 22px 6px 14px; border-radius: 3px; }}
QMenu::item:selected {{ background-color: {ACCENT}; }}
QMenu::item:disabled {{ color: {TEXT_DISABLED}; }}
QMenu::separator {{ height: 1px; background-color: {BORDER}; margin: 4px 6px; }}
/* ---- sliders (seek and volume were fully native) --------------------- */
QSlider::groove:horizontal {{
height: 4px;
background-color: {BORDER};
border-radius: 2px;
}}
QSlider::sub-page:horizontal {{
background-color: {ACCENT};
border-radius: 2px;
}}
QSlider::handle:horizontal {{
background-color: {TEXT};
width: 12px;
height: 12px;
margin: -5px 0;
border-radius: 6px;
}}
QSlider::handle:horizontal:hover {{ background-color: {FOCUS}; }}
QSlider::handle:horizontal:disabled {{ background-color: {TEXT_DISABLED}; }}
QSlider::groove:vertical {{ width: 4px; background-color: {BORDER}; border-radius: 2px; }}
QSlider::handle:vertical {{
background-color: {TEXT}; height: 12px; margin: 0 -5px; border-radius: 6px;
}}
/* ---- splitters ------------------------------------------------------- */
QSplitter::handle {{ background-color: {BORDER}; }}
QSplitter::handle:horizontal {{ width: 4px; }}
QSplitter::handle:vertical {{ height: 4px; }}
QSplitter::handle:hover {{ background-color: {ACCENT}; }}
/* ---- tooltips (were rendering in the system's light style) ----------- */
QToolTip {{
background-color: {SURFACE_ALT};
color: {TEXT};
border: 1px solid {BORDER};
padding: 5px 8px;
border-radius: 4px;
}}
/* ---- horizontal scrollbar (only the vertical one was styled) --------- */
QScrollBar:horizontal {{
background-color: {BG};
height: 12px;
margin: 0;
border: none;
}}
QScrollBar::handle:horizontal {{
background-color: {BORDER_STRONG};
min-width: 24px;
border-radius: 6px;
}}
QScrollBar::handle:horizontal:hover {{ background-color: {ACCENT}; }}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
width: 0; border: none; background: none;
}}
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: none; }}
/* ---- assorted controls left unstyled --------------------------------- */
QRadioButton, QCheckBox {{ color: {TEXT}; spacing: 7px; }}
QRadioButton:disabled, QCheckBox:disabled {{ color: {TEXT_DISABLED}; }}
QGroupBox {{
border: 1px solid {BORDER};
border-radius: 5px;
margin-top: 10px;
padding-top: 8px;
color: {TEXT};
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
color: {TEXT_MUTED};
}}
QSpinBox, QDoubleSpinBox {{
background-color: {SURFACE};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 4px 6px;
}}
/* ---- corrections to rules that already exist ------------------------- */
/* Upstream's QPushButton:pressed changes the padding, which shifts every
label two pixels down-and-right on click and clips the artwork on the
fixed-width icon buttons. The background change alone reads as pressed. */
QPushButton:pressed {{
padding: 8px 15px;
}}
/* One consistent icon size everywhere, since icons are now real artwork. */
QPushButton, QToolButton {{
qproperty-iconSize: 18px 18px;
}}
QToolButton {{
background-color: transparent;
border: none;
border-radius: 4px;
padding: 5px;
color: {TEXT};
}}
QToolButton:hover {{ background-color: {SURFACE_HOVER}; }}
QToolButton:pressed {{ background-color: {BORDER}; }}
"""
+159
View File
@@ -0,0 +1,159 @@
"""
Application-wide button polish
==============================
Two problems, both spread across every file in the GUI:
- **Emoji standing in for icons.** `cards.download` is the single character
"" on a button with no text and no tooltip; the queue's clear button is
""; the downloads folder button is "📁". These depend entirely on a system
emoji font, and render as tofu boxes without one.
- **Almost nothing has a tooltip.** Three widgets in the whole application set
one, and nothing sets an accessible name -- so icon-only buttons are
unidentifiable to sighted users and invisible to a screen reader alike.
Fixing that at the call sites would mean editing well over a hundred of them,
most in files owned by upstream YTSage, which would conflict on the next merge.
Instead this installs one event filter on the QApplication and handles
`QEvent.Polish`, which Qt sends to every widget exactly once before it is first
shown -- including widgets inside dialogs built by upstream code, and including
any added later. One hook, whole tree, no call-site edits.
New code does not need the glyph map: give a button a `sageIcon` property and
this will resolve it.
btn.setProperty("sageIcon", "download")
"""
from typing import Dict, Optional
from PySide6.QtCore import QEvent, QObject
from PySide6.QtWidgets import QAbstractButton, QApplication
from . import ytsage_icons as icons
from . import ytsage_theme as theme
from ..utils.ytsage_logger import logger
#: Emoji that are standing in for icons, and what they actually mean.
#: Matched as the whole label, or as a leading glyph followed by real text
#: ("▶ Play"), never mid-string.
GLYPH_ICONS: Dict[str, str] = {
"": "download",
"": "play",
"": "pause",
"": "stop",
"": "x",
"": "x",
"×": "x",
"": "plus",
"+": "plus",
"📁": "folder-open",
"📂": "folder-open",
"🔄": "refresh",
"🗑": "trash",
"": "settings",
"🔍": "search",
}
#: Tooltips for buttons whose label is only artwork once the glyph is
#: replaced, so they do not end up with no description at all.
ICON_TOOLTIPS: Dict[str, str] = {
"download": "Download",
"play": "Play",
"pause": "Pause",
"stop": "Stop",
"x": "Clear",
"plus": "Add",
"folder-open": "Open folder",
"refresh": "Refresh",
"trash": "Delete",
"settings": "Settings",
"search": "Search",
"maximize": "Fullscreen",
"minimize": "Leave fullscreen",
"volume-mute": "Mute",
"skip-forward": "Next",
"skip-back": "Previous",
}
def _strip_accelerator(text: str) -> str:
""""&Yes" -> "Yes". Qt's mnemonic markers must not defeat matching."""
return text.replace("&&", "\x00").replace("&", "").replace("\x00", "&")
class ButtonPolisher(QObject):
"""Turns placeholder glyphs into icons and fills in missing descriptions."""
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
if event.type() == QEvent.Type.Polish and isinstance(obj, QAbstractButton):
try:
self._polish_button(obj)
except Exception as e:
# Cosmetics must never break a dialog from opening.
logger.debug(f"Button polish skipped: {e}")
return False
# ------------------------------------------------------------------
def _polish_button(self, btn: QAbstractButton) -> None:
icon_name: Optional[str] = None
# 1. Explicit opt-in wins.
declared = btn.property("sageIcon")
if declared:
icon_name = str(declared)
raw_text = btn.text() or ""
text = _strip_accelerator(raw_text).strip()
# 2. Otherwise infer from a placeholder glyph. Buttons carrying a
# mnemonic are left alone: those are standard dialog buttons.
if icon_name is None and "&" not in raw_text and text:
first = text[0]
if text in GLYPH_ICONS:
icon_name = GLYPH_ICONS[text]
text = "" # the glyph *was* the whole label
elif first in GLYPH_ICONS and len(text) > 1 and text[1] in " \t":
icon_name = GLYPH_ICONS[first]
text = text[1:].strip()
if icon_name is not None and btn.icon().isNull():
# White reads on the accent-coloured buttons; the muted token
# would disappear into them.
btn.setIcon(icons.icon(icon_name, theme.ICON_ON_ACCENT))
if text != _strip_accelerator(raw_text).strip():
btn.setText(text)
# 3. Every button gets something to describe it.
label = _strip_accelerator(btn.text() or "").strip()
if not btn.toolTip():
if label:
btn.setToolTip(label)
elif icon_name and icon_name in ICON_TOOLTIPS:
btn.setToolTip(ICON_TOOLTIPS[icon_name])
elif not btn.icon().isNull():
# An icon-only button nobody has described. Name it so the
# gap is findable rather than silent.
logger.debug(f"Icon-only button without a tooltip: {btn.objectName() or btn!r}")
if not btn.accessibleName():
btn.setAccessibleName(label or btn.toolTip())
_polisher: Optional[ButtonPolisher] = None
def install(app: Optional[QApplication] = None) -> None:
"""Install once, before the main window is built."""
global _polisher
if _polisher is not None:
return
app = app or QApplication.instance()
if app is None:
logger.debug("UI polish not installed: no QApplication yet.")
return
_polisher = ButtonPolisher(app)
app.installEventFilter(_polisher)
logger.debug("Button polish filter installed.")
+74 -9
View File
@@ -305,8 +305,8 @@
"ytdlp_channel_switched": "✅ Successfully switched to {channel} channel!", "ytdlp_channel_switched": "✅ Successfully switched to {channel} channel!",
"ytdlp_channel_switch_failed": "❌ Failed to switch channel: {error}", "ytdlp_channel_switch_failed": "❌ Failed to switch channel: {error}",
"ytdlp_current_channel": "Current channel: {channel}", "ytdlp_current_channel": "Current channel: {channel}",
"app_updates_title": "YTSage Updates", "app_updates_title": "SageTube Updates",
"check_app_updates": "Check for YTSage updates on startup", "check_app_updates": "Check for SageTube updates on startup",
"check_beta_updates": "Receive Beta Updates", "check_beta_updates": "Receive Beta Updates",
"auto_update_title": "Auto-Update Settings", "auto_update_title": "Auto-Update Settings",
"auto_update_header": "🔄 Auto-Update Settings", "auto_update_header": "🔄 Auto-Update Settings",
@@ -466,12 +466,15 @@
}, },
"update_dialog": { "update_dialog": {
"title": "Update Available", "title": "Update Available",
"new_version_available": "A new version of YTSage is available!", "new_version_available": "A new version of SageTube is available!",
"current_version_label": "Current version:", "current_version_label": "Current version:",
"latest_version_label": "Latest version:", "latest_version_label": "Latest version:",
"changelog": "Changelog", "changelog": "Changelog",
"download_update": "Download Update", "download_update": "Download Update",
"remind_later": "Remind Me Later" "remind_later": "Remind Me Later",
"skip_version": "Skip This Version",
"skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.",
"changelog_unavailable": "No release notes were published for this version."
}, },
"playlist": { "playlist": {
"unknown": "Unknown Playlist", "unknown": "Unknown Playlist",
@@ -648,14 +651,53 @@
"queue": "Queue", "queue": "Queue",
"play": "Play", "play": "Play",
"pause": "Pause", "pause": "Pause",
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality." "stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.",
"play_pause": "Play / pause (Space)",
"fullscreen": "Fullscreen (F)",
"seek_tooltip": "Seek through the video",
"quality_tooltip": "Maximum playback resolution",
"speed_tooltip": "Playback speed",
"volume_tooltip": "Volume",
"subtitles_tooltip": "Toggle subtitles (C)",
"previous": "Previous in queue (P)",
"next": "Next in queue (N)",
"buffering": "Buffering…",
"act_play_pause": "Play / pause",
"act_seek_back_5": "Back 5 seconds",
"act_seek_fwd_5": "Forward 5 seconds",
"act_seek_back_10": "Back 10 seconds",
"act_seek_fwd_10": "Forward 10 seconds",
"act_seek_back_1": "Back 1 second",
"act_seek_fwd_1": "Forward 1 second",
"act_frame_back": "Previous frame",
"act_frame_fwd": "Next frame",
"act_vol_up": "Volume up",
"act_vol_down": "Volume down",
"act_mute": "Mute",
"act_unmute": "Unmute",
"act_speed_up": "Speed up",
"act_speed_down": "Slow down",
"act_speed_reset": "Normal speed",
"act_subtitles": "Subtitles on / off",
"act_fullscreen": "Fullscreen",
"act_leave_fullscreen": "Leave fullscreen",
"act_next": "Next in queue",
"act_previous": "Previous",
"act_start": "Back to start",
"act_end": "Jump to end",
"act_copy_url": "Copy video link",
"act_open_browser": "Open in browser"
}, },
"cards": { "cards": {
"play": "▶ Play", "play": "▶ Play",
"queue": "+ Queue", "queue": "+ Queue",
"download": "⬇", "download": "⬇",
"load_more": "Load more", "load_more": "Load more",
"empty": "Nothing here yet" "empty": "Nothing here yet",
"play_tooltip": "Play this video now",
"queue_tooltip": "Add to the play queue",
"download_tooltip": "Open in Downloads with this video ready to analyse",
"load_more_tooltip": "Load the next page of results"
}, },
"main_tabs": { "main_tabs": {
"watch": "Watch", "watch": "Watch",
@@ -669,7 +711,8 @@
"button": "Search", "button": "Search",
"searching": "Searching...", "searching": "Searching...",
"results_count": "{count} results", "results_count": "{count} results",
"failed": "Search failed: {error}" "failed": "Search failed: {error}",
"button_tooltip": "Search YouTube"
}, },
"watch": { "watch": {
"clear_queue": "Clear queue" "clear_queue": "Clear queue"
@@ -687,7 +730,13 @@
"loading": "Loading...", "loading": "Loading...",
"entry_count": "{count} videos", "entry_count": "{count} videos",
"failed": "Failed to load: {error}", "failed": "Failed to load: {error}",
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search." "hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.",
"open_tooltip": "Open this channel or playlist",
"subscribe_tooltip": "Follow this channel so its uploads appear in your Feed",
"unsubscribe_tooltip": "Stop following this channel",
"play_all_tooltip": "Queue everything loaded here",
"download_playlist_tooltip": "Open this playlist in Downloads",
"queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest"
}, },
"feed": { "feed": {
"mode_local": "Local subscriptions", "mode_local": "Local subscriptions",
@@ -702,6 +751,22 @@
"account_failed": "Account feed failed (are cookies valid?): {error}", "account_failed": "Account feed failed (are cookies valid?): {error}",
"subscribed": "Subscribed to {title}", "subscribed": "Subscribed to {title}",
"unsubscribed": "Unsubscribed from {title}", "unsubscribed": "Unsubscribed from {title}",
"open_channel": "Open channel" "open_channel": "Open channel",
"refresh_tooltip": "Fetch the latest uploads from every subscribed channel",
"mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account",
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
"account_needs_refresh": "Press Refresh to load your account feed",
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
"sign_in": "Sign in with cookies…",
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
"account_requires_cookies": "Requires signing in with cookies"
},
"account": {
"signed_out": "Not signed in",
"signed_out_tooltip": "SageTube is not using a YouTube account. Sign in with browser cookies to use your subscription feed and reach age-restricted or members-only videos.",
"signed_in": "Account: {source}",
"signed_in_tooltip": "Using cookies from {source}. Click to change or clear them.",
"source_file": "cookie file"
} }
} }
+36 -1
View File
@@ -1,11 +1,39 @@
import sys import sys
from PySide6.QtCore import QCoreApplication, Qt
from PySide6.QtGui import QSurfaceFormat
from PySide6.QtWidgets import QApplication, QMessageBox from PySide6.QtWidgets import QApplication, QMessageBox
from .utils.ytsage_logger import logger from .utils.ytsage_logger import logger
from .gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main from .gui.ytsage_gui_main import YTSageApp # Import the main application class from ytsage_gui_main
def _configure_opengl() -> None:
"""
Must run before QApplication exists -- both settings are ignored afterwards.
AA_ShareOpenGLContexts puts every widget context in one sharing group, so
a reparent no longer loses GL *resources*. It does not stop Qt destroying
and recreating the widget's own context (MpvRenderWidget handles that);
it removes a whole second class of failure around it.
The surface format is deliberately minimal. mpv renders into our FBO and
needs no depth, stencil or alpha from the default framebuffer, and asking
for them is what produced `OpenGL error INVALID_ENUM` on some drivers. No
GL version or profile is requested on purpose: pinning a core profile
breaks software and GLES stacks that libmpv would otherwise accept.
"""
QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
fmt = QSurfaceFormat()
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
fmt.setSwapInterval(1)
fmt.setDepthBufferSize(0)
fmt.setStencilBufferSize(0)
fmt.setAlphaBufferSize(0)
QSurfaceFormat.setDefaultFormat(fmt)
def show_error_dialog(message): def show_error_dialog(message):
# A QMessageBox needs a live QApplication; if startup failed before (or # A QMessageBox needs a live QApplication; if startup failed before (or
# while) creating one, constructing the dialog would abort the process # while) creating one, constructing the dialog would abort the process
@@ -23,11 +51,18 @@ def show_error_dialog(message):
def main(): def main():
try: try:
logger.info("Starting YTSage application") logger.info("Starting SageTube application")
_configure_opengl()
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("SageTube") app.setApplicationName("SageTube")
app.setDesktopFileName("sagetube") app.setDesktopFileName("sagetube")
# Before the main window: this retro-fits icons, tooltips and
# accessible names onto every button as it is first polished.
from .gui.ytsage_ui_polish import install as install_ui_polish
install_ui_polish(app)
window = YTSageApp() # Instantiate the main application class window = YTSageApp() # Instantiate the main application class
window.show() window.show()
logger.info("Application window shown, entering main loop") logger.info("Application window shown, entering main loop")
+76 -2
View File
@@ -72,6 +72,9 @@ class ConfigManager:
_config_file: Path = APP_CONFIG_FILE _config_file: Path = APP_CONFIG_FILE
_settings: Dict[str, Any] = {} _settings: Dict[str, Any] = {}
_default_config: Dict[str, Any] = { _default_config: Dict[str, Any] = {
# Bumped whenever a stored config needs rewriting rather than merely
# merging; see _migrate(). Absent means "written before 5.4.0".
"config_version": 2,
"download_path": str(USER_HOME_DIR / "Downloads"), "download_path": str(USER_HOME_DIR / "Downloads"),
"generic_mode": True, "generic_mode": True,
"speed_limit_value": None, "speed_limit_value": None,
@@ -89,6 +92,7 @@ class ConfigManager:
"check_app_updates": False, # fork updates come from Gitea, not the upstream PyPI package "check_app_updates": False, # fork updates come from Gitea, not the upstream PyPI package
"check_beta_updates": False, "check_beta_updates": False,
"last_update_check": 0, "last_update_check": 0,
"skipped_update_version": None, # set by the update dialog's "Skip this version"
"concurrent_fragments": 1, "concurrent_fragments": 1,
"language": "en", "language": "en",
"ytdlp_channel": "stable", "ytdlp_channel": "stable",
@@ -114,14 +118,74 @@ class ConfigManager:
"volume": 100, "volume": 100,
"resume": "auto", # auto | off "resume": "auto", # auto | off
"source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs) "source_mode": "ytdl", # ytdl (mpv ytdl_hook) | direct (raw stream URLs)
# "auto" picks a platform list (vaapi on Linux, d3d11va on Windows,
# videotoolbox on macOS), each ending in "no" so a broken interop
# falls back to software rather than a black frame. Any other value
# is passed to mpv verbatim; "no" forces software decoding.
"hwdec": "auto",
}, },
"feed": { "feed": {
"mode": "local", # local (per-channel aggregation) | account (cookies) "mode": "local", # local (per-channel aggregation) | account (cookies)
"per_channel_items": 15, "per_channel_items": 15,
"auto_refresh_minutes": 0, # 0 = manual refresh only "auto_refresh_minutes": 0, # 0 = manual refresh only
# Opening the Feed tab refreshes channels not seen for this long.
# 0 disables it. A separate key from auto_refresh_minutes above so
# existing configs, which store that as 0, are not read as opting
# out of a feature that did not exist when they were written.
"auto_refresh_on_open_minutes": 30,
}, },
} }
@classmethod
def _deep_merge(cls, defaults: Dict[str, Any], stored: Dict[str, Any]) -> Dict[str, Any]:
"""
Recursively layer `stored` on top of `defaults`.
A shallow `dict.update()` here would be a data-loss bug: a config file
written by an older build contains a *partial* "player" or "feed"
object, and updating shallowly replaces the whole nested default with
it -- so every key added since would come back missing.
Rules:
- dict + dict recurse
- anything else (lists, scalars, type mismatches) replaces wholesale
- keys present only in `stored` are kept, never pruned; a user who
downgrades must not have their settings destroyed by the older build
"""
merged = copy.deepcopy(defaults)
for key, value in stored.items():
existing = merged.get(key)
if isinstance(value, dict) and isinstance(existing, dict):
merged[key] = cls._deep_merge(existing, value)
else:
merged[key] = copy.deepcopy(value)
return merged
@classmethod
def _migrate(cls, stored: Dict[str, Any]) -> bool:
"""
Bring a stored config up to CONFIG_VERSION in place.
Returns True when something changed, so the caller knows to re-save.
Migrations must be idempotent and must never raise -- a config that
cannot be migrated is still a config the app has to start with.
"""
changed = False
version = stored.get("config_version")
if not isinstance(version, int) or version < 2:
# Pre-5.4.0, and possibly inherited from an upstream YTSage
# install. Such a file carries "check_app_updates": true, which
# used to point at PyPI's `ytsage` package and oop7/YTSage's
# releases -- neither of which is this application. Dropping the
# stored value lets SageTube's own default decide.
if stored.pop("check_app_updates", None) is not None:
logger.info("Config migration: cleared inherited 'check_app_updates'.")
stored["config_version"] = 2
changed = True
return changed
@classmethod @classmethod
def _load(cls) -> None: def _load(cls) -> None:
""" """
@@ -134,11 +198,21 @@ class ConfigManager:
try: try:
with open(cls._config_file, "r", encoding="utf-8") as f: with open(cls._config_file, "r", encoding="utf-8") as f:
stored = json.load(f) stored = json.load(f)
if not isinstance(stored, dict):
raise json.JSONDecodeError("config root is not an object", "", 0)
migrated = False
try:
migrated = cls._migrate(stored)
except Exception as e:
logger.exception(f"Config migration failed, continuing unmigrated: {e}")
# Merge on top of defaults so keys added in newer versions # Merge on top of defaults so keys added in newer versions
# exist without call sites needing `or <default>` fallbacks # exist without call sites needing `or <default>` fallbacks
cls._settings = copy.deepcopy(cls._default_config) cls._settings = cls._deep_merge(cls._default_config, stored)
cls._settings.update(stored)
logger.info("Config loaded from file.") logger.info("Config loaded from file.")
if migrated:
cls._save()
except json.JSONDecodeError: except json.JSONDecodeError:
cls._settings = copy.deepcopy(cls._default_config) cls._settings = copy.deepcopy(cls._default_config)
logger.warning("Config file corrupt, loaded defaults.") logger.warning("Config file corrupt, loaded defaults.")
+2
View File
@@ -78,6 +78,8 @@ def get_asset_path(asset_relative_path: str) -> Path:
# Assets Constants # Assets Constants
ICON_PATH: Path = get_asset_path("assets/Icon/icon.png") ICON_PATH: Path = get_asset_path("assets/Icon/icon.png")
# The 48px original is what the taskbar and window manager had to upscale.
ICON_PATH_LARGE: Path = get_asset_path("assets/Icon/icon-256.png")
SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3") SOUND_PATH: Path = get_asset_path("assets/sound/notification.mp3")
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
+19 -6
View File
@@ -165,14 +165,27 @@ class LibraryManager:
conn.commit() conn.commit()
@classmethod @classmethod
def feed_items(cls, limit: int = 120) -> List[Dict[str, Any]]: def feed_items(cls, limit: int = 120, channel_id: Optional[str] = None) -> List[Dict[str, Any]]:
with cls._lock: """
rows = cls._conn().execute( Feed rows, newest first. `channel_id` narrows to one channel, which is
what lets the Feed merge a finished channel's videos into the grid
instead of rebuilding every card.
"""
query = (
"SELECT f.*, s.title AS channel FROM feed_items f " "SELECT f.*, s.title AS channel FROM feed_items f "
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id " "LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
"ORDER BY COALESCE(f.published_ts, f.fetched_at) DESC LIMIT ?", )
(limit,), params: List[Any] = []
).fetchall() if channel_id is not None:
query += "WHERE f.channel_id = ? "
params.append(str(channel_id))
# published_ts first and only then fetched_at: mixing the two in a
# single COALESCE sorted real publish times against wall-clock fetch
# times, so whichever channel refreshed last floated to the top.
query += "ORDER BY f.published_ts IS NULL, f.published_ts DESC, f.fetched_at DESC LIMIT ?"
params.append(limit)
with cls._lock:
rows = cls._conn().execute(query, tuple(params)).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ------------------------------------------------------- watch history # ------------------------------------------------------- watch history
+140 -2
View File
@@ -74,10 +74,148 @@ class LocalizationManager:
"generic_mode": "Generic Mode", "generic_mode": "Generic Mode",
"enable_generic_mode": "Enable Generic Mode (support non-YouTube sites)", "enable_generic_mode": "Enable Generic Mode (support non-YouTube sites)",
"generic_mode_help": "Allows downloading from Dailymotion, CBC Gem, and other sites supported by yt-dlp.", "generic_mode_help": "Allows downloading from Dailymotion, CBC Gem, and other sites supported by yt-dlp.",
"app_updates_title": "YTSage Updates", "app_updates_title": "SageTube Updates",
"check_app_updates": "Check for YTSage updates on startup", "check_app_updates": "Check for SageTube updates on startup",
"check_beta_updates": "Receive Beta Updates" "check_beta_updates": "Receive Beta Updates"
}, },
# --- SageTube's own strings -------------------------------------
# These namespaces exist only in en.json: the fork added them and the
# thirteen translated files predate them. get_text() falls back to this
# dict before giving up and returning the key itself, so without these
# a non-English UI literally reads "main_tabs.watch" on its tabs.
# Keep in step with en.json when adding keys; translating the language
# files is the separate, larger job.
"main_tabs": {
"watch": "Watch",
"search": "Search",
"feed": "Feed",
"browse": "Browse",
"downloads": "Downloads"
},
"search": {
"placeholder": "Search YouTube...",
"button": "Search",
"searching": "Searching...",
"results_count": "{count} results",
"failed": "Search failed: {error}",
"button_tooltip": "Search YouTube"
},
"browse": {
"placeholder": "Paste a channel or playlist URL...",
"open": "Open",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"play_all": "Play all",
"download_playlist": "Download",
"tab_videos": "Videos",
"tab_shorts": "Shorts",
"tab_live": "Live",
"loading": "Loading...",
"entry_count": "{count} videos",
"failed": "Failed to load: {error}",
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search.",
"open_tooltip": "Open this channel or playlist",
"subscribe_tooltip": "Follow this channel so its uploads appear in your Feed",
"unsubscribe_tooltip": "Stop following this channel",
"play_all_tooltip": "Queue everything loaded here",
"download_playlist_tooltip": "Open this playlist in Downloads",
"queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest"
},
"feed": {
"mode_local": "Local subscriptions",
"mode_account": "YouTube account (cookies)",
"refresh": "Refresh",
"subscriptions": "Subscriptions",
"no_subscriptions": "No subscriptions yet - subscribe from a channel page in Browse",
"refreshing": "Refreshing... {done}/{total}",
"refreshed": "{count} videos in feed",
"channel_failed": "A channel failed to refresh: {error}",
"fetching_account": "Fetching your subscription feed...",
"account_failed": "Account feed failed (are cookies valid?): {error}",
"subscribed": "Subscribed to {title}",
"unsubscribed": "Unsubscribed from {title}",
"open_channel": "Open channel",
"refresh_tooltip": "Fetch the latest uploads from every subscribed channel",
"mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account",
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
"account_needs_refresh": "Press Refresh to load your account feed",
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
"sign_in": "Sign in with cookies…",
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
"account_requires_cookies": "Requires signing in with cookies"
},
"cards": {
"play": "▶ Play",
"queue": "+ Queue",
"download": "",
"load_more": "Load more",
"empty": "Nothing here yet",
"play_tooltip": "Play this video now",
"queue_tooltip": "Add to the play queue",
"download_tooltip": "Open in Downloads with this video ready to analyse",
"load_more_tooltip": "Load the next page of results"
},
"watch": {
"clear_queue": "Clear queue"
},
"player": {
"quality_auto": "Auto",
"subtitles": "CC",
"unavailable": "Video player unavailable",
"now_playing": "Now Playing",
"queue": "Queue",
"play": "Play",
"pause": "Pause",
"stream_stalled": "Stream failed to start after several attempts - YouTube may be throttling. Try again or pick a lower quality.",
"play_pause": "Play / pause (Space)",
"fullscreen": "Fullscreen (F)",
"seek_tooltip": "Seek through the video",
"quality_tooltip": "Maximum playback resolution",
"speed_tooltip": "Playback speed",
"volume_tooltip": "Volume",
"subtitles_tooltip": "Toggle subtitles (C)",
"previous": "Previous in queue (P)",
"next": "Next in queue (N)",
"buffering": "Buffering…",
"act_play_pause": "Play / pause",
"act_seek_back_5": "Back 5 seconds",
"act_seek_fwd_5": "Forward 5 seconds",
"act_seek_back_10": "Back 10 seconds",
"act_seek_fwd_10": "Forward 10 seconds",
"act_seek_back_1": "Back 1 second",
"act_seek_fwd_1": "Forward 1 second",
"act_frame_back": "Previous frame",
"act_frame_fwd": "Next frame",
"act_vol_up": "Volume up",
"act_vol_down": "Volume down",
"act_mute": "Mute",
"act_unmute": "Unmute",
"act_speed_up": "Speed up",
"act_speed_down": "Slow down",
"act_speed_reset": "Normal speed",
"act_subtitles": "Subtitles on / off",
"act_fullscreen": "Fullscreen",
"act_leave_fullscreen": "Leave fullscreen",
"act_next": "Next in queue",
"act_previous": "Previous",
"act_start": "Back to start",
"act_end": "Jump to end",
"act_copy_url": "Copy video link",
"act_open_browser": "Open in browser"
},
"account": {
"signed_out": "Not signed in",
"signed_out_tooltip": "SageTube is not using a YouTube account. Sign in with browser cookies to use your subscription feed and reach age-restricted or members-only videos.",
"signed_in": "Account: {source}",
"signed_in_tooltip": "Using cookies from {source}. Click to change or clear them.",
"source_file": "cookie file"
},
"update_dialog": {
"skip_version": "Skip This Version",
"skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.",
"changelog_unavailable": "No release notes were published for this version."
},
"tabs": { "tabs": {
"cookies": "Login with Cookies", "cookies": "Login with Cookies",
"custom_command": "Custom Command", "custom_command": "Custom Command",