Compare commits
12 Commits
b4ff618d41
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
996d581a07
|
|||
|
ba05a4a7ef
|
|||
|
b07a56082a
|
|||
|
9c4749e49b
|
|||
|
fd744a1a85
|
|||
|
79a6f26c2f
|
|||
|
27933dd495
|
|||
|
3aca43b372
|
|||
|
5ae2817eea
|
|||
|
2420091a4e
|
|||
|
089b144626
|
|||
|
b3772ff484
|
+230
@@ -0,0 +1,230 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Kept as the work happens, not assembled at release time. Format is
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
||||||
|
[SemVer](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
**The `vX.Y.Z` tags already on this repository are upstream YTSage's**, inherited
|
||||||
|
with its history. SageTube's own versions start below and are the ones this file
|
||||||
|
records.
|
||||||
|
|
||||||
|
## 5.5.2 — 2026-08-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **The account feed loaded but never appeared.** Refresh reported "60 videos in
|
||||||
|
feed" above a "No subscriptions yet" message and an empty page: the empty-state
|
||||||
|
hint hides the grid so it can sit centred, and the account path set its entries
|
||||||
|
without re-running that check — so the grid stayed hidden from startup. The
|
||||||
|
local feed was unaffected, which is why it went unnoticed.
|
||||||
|
- The empty-state message was written for local subscriptions and was wrong
|
||||||
|
advice in account mode, where local subscriptions are not used at all. It now
|
||||||
|
says to press Refresh instead of telling you to go and subscribe to something.
|
||||||
|
- The Subscriptions panel is hidden in account mode rather than sitting empty
|
||||||
|
beside a full feed, implying something failed.
|
||||||
|
|
||||||
|
## 5.5.1 — 2026-08-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **The app could start fullscreen with no way out.** Fullscreen became a state
|
||||||
|
of the main window in 5.5.0, and the window's geometry is saved on exit — so
|
||||||
|
quitting from fullscreen brought the app back fullscreen, with no title bar,
|
||||||
|
no close button, and nothing bound to leave it (the player's F and Escape only
|
||||||
|
work while the player has focus, and it need not even be the visible tab).
|
||||||
|
Fullscreen is now never restored at startup, leaving it is done before the
|
||||||
|
geometry is saved, and **F11 toggles fullscreen and Escape leaves it from
|
||||||
|
anywhere in the window**.
|
||||||
|
- **"Sign in with cookies" and the account button did nothing.** Both open the
|
||||||
|
cookie dialog on its Cookies tab, which meant calling `setCurrentIndex` on
|
||||||
|
the dialog's tab widget — a `SmoothTabWidget`, which despite standing in for
|
||||||
|
a `QTabWidget` implemented only `set_current_index`. The call raised, so the
|
||||||
|
dialog never appeared. `SmoothTabWidget` now provides the Qt-compatible API
|
||||||
|
it claimed to have (`setCurrentIndex`, `count`, `widget`, `indexOf`,
|
||||||
|
`tabText`, `setTabText`).
|
||||||
|
|
||||||
|
## 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, 0–9 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
|
||||||
|
|
||||||
|
- The documentation left the repository: `docs/BUILDING.md`, `docs/UPSTREAM.md`
|
||||||
|
and `docs/UPSTREAM_README.md` are now the
|
||||||
|
[wiki](https://git.houmeres.sk/Houmeres/SageTube/wiki).
|
||||||
|
- **The history was deliberately not rewritten.** Every other repository in this
|
||||||
|
org had its documentation removed from every commit on 2026-08-08. SageTube is
|
||||||
|
a fork that still merges from `oop7/YTSage`, and a rewrite changes every commit
|
||||||
|
id — which would end that. The files leave in an ordinary commit instead.
|
||||||
|
- `.github/` and `readme-translations/` are upstream's and were left alone, so
|
||||||
|
the next merge does not conflict over them.
|
||||||
@@ -20,7 +20,7 @@ with the full yt-dlp download feature set inherited from
|
|||||||
**Downloading** (from YTSage)
|
**Downloading** (from YTSage)
|
||||||
- Format table, audio extraction, subtitles, SponsorBlock, chapters,
|
- Format table, audio extraction, subtitles, SponsorBlock, chapters,
|
||||||
playlists, trimming, speed limits, proxies, cookies, custom yt-dlp commands,
|
playlists, trimming, speed limits, proxies, cookies, custom yt-dlp commands,
|
||||||
download history — see the [upstream README](docs/UPSTREAM_README.md)
|
download history — see the [upstream README](https://git.houmeres.sk/Houmeres/SageTube/wiki/Upstream-README)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -30,14 +30,14 @@ sagetube
|
|||||||
```
|
```
|
||||||
|
|
||||||
Requires **libmpv** for playback (Arch: `pacman -S mpv`) — see
|
Requires **libmpv** for playback (Arch: `pacman -S mpv`) — see
|
||||||
[docs/BUILDING.md](docs/BUILDING.md). Without it the app works as a downloader.
|
[Building](https://git.houmeres.sk/Houmeres/SageTube/wiki/Building). Without it the app works as a downloader.
|
||||||
|
|
||||||
## Fork notes
|
## Fork notes
|
||||||
|
|
||||||
SageTube is a fork of [YTSage](https://github.com/oop7/YTSage) by
|
SageTube is a fork of [YTSage](https://github.com/oop7/YTSage) by
|
||||||
[oop7](https://github.com/oop7) (MIT). The internal package keeps the
|
[oop7](https://github.com/oop7) (MIT). The internal package keeps the
|
||||||
`ytsage` name so upstream fixes remain mergeable — see
|
`ytsage` name so upstream fixes remain mergeable — see
|
||||||
[docs/UPSTREAM.md](docs/UPSTREAM.md). The fork also fixes a number of
|
[Tracking upstream](https://git.houmeres.sk/Houmeres/SageTube/wiki/Tracking-upstream). The fork also fixes a number of
|
||||||
upstream bugs (unsafe partial-file cleanup, unverified binary updates,
|
upstream bugs (unsafe partial-file cleanup, unverified binary updates,
|
||||||
thread-safety issues, PATH corruption on Windows — see the git log).
|
thread-safety issues, PATH corruption on Windows — see the git log).
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# Building & running SageTube
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Python >= 3.10
|
|
||||||
- **libmpv** (system library; `python-mpv` binds to it at runtime)
|
|
||||||
- Arch: `sudo pacman -S mpv`
|
|
||||||
- Debian/Ubuntu: `sudo apt install libmpv2`
|
|
||||||
- Fedora: `sudo dnf install mpv-libs`
|
|
||||||
- macOS: `brew install mpv`
|
|
||||||
- Windows: place `libmpv-2.dll` next to the executable (builds from
|
|
||||||
https://github.com/shinchiro/mpv-winbuild-cmake/releases), or install mpv
|
|
||||||
and add it to PATH
|
|
||||||
- ffmpeg (for download merging; the app can auto-install it)
|
|
||||||
|
|
||||||
Without libmpv the app still runs — the Watch tab shows an install hint and
|
|
||||||
search/browse/download work normally.
|
|
||||||
|
|
||||||
## Run from source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install -e .
|
|
||||||
sagetube
|
|
||||||
```
|
|
||||||
|
|
||||||
On first start the app downloads its own SHA256-verified `yt-dlp` binary and
|
|
||||||
the Deno runtime (yt-dlp's JS challenge solver) into the SageTube data dir:
|
|
||||||
|
|
||||||
- Linux: `~/.local/share/SageTube/bin/`
|
|
||||||
- macOS: `~/Library/Application Support/SageTube/bin/`
|
|
||||||
- Windows: `%LOCALAPPDATA%\SageTube\bin\`
|
|
||||||
|
|
||||||
## Notes on YouTube playback reliability
|
|
||||||
|
|
||||||
Playback resolves streams through mpv's `ytdl_hook` using the managed yt-dlp.
|
|
||||||
YouTube's CDN intermittently serves stalled streams to non-browser clients;
|
|
||||||
the player retries automatically (twice, after 25 s of no playback). Keeping
|
|
||||||
yt-dlp updated (built-in updater, stable or nightly channel) is the long-term
|
|
||||||
fix channel for extractor breakage.
|
|
||||||
|
|
||||||
## Release workflows
|
|
||||||
|
|
||||||
The `.github/workflows/` files are inherited from upstream YTSage and target
|
|
||||||
GitHub runners; they do not run on Gitea. If you set up Gitea Actions, note
|
|
||||||
that the upstream Linux/Windows workflows download appimagetool and a bundled
|
|
||||||
ffmpeg **without checksum verification** — add hash checks before reusing
|
|
||||||
them for release artifacts.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Tracking upstream YTSage
|
|
||||||
|
|
||||||
SageTube keeps the full git history of [YTSage](https://github.com/oop7/YTSage)
|
|
||||||
and the internal `ytsage` package name so upstream improvements can be merged.
|
|
||||||
|
|
||||||
## Syncing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch upstream # upstream = https://github.com/oop7/YTSage.git
|
|
||||||
git merge upstream/main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Expected conflict zones
|
|
||||||
|
|
||||||
Most SageTube features live in **new files** and merge cleanly. Conflicts are
|
|
||||||
likely only in:
|
|
||||||
|
|
||||||
| File | Reason |
|
|
||||||
|---|---|
|
|
||||||
| `ytsage/gui/ytsage_gui_main.py` | tab-shell edit in `init_ui` (`_setup_main_tabs`), closeEvent hook |
|
|
||||||
| `ytsage/gui/ytsage_gui_analysis.py` | delegates subprocess execution to `YtdlpClient` |
|
|
||||||
| `ytsage/utils/ytsage_constants.py` | SageTube data dirs, PATH prepend for managed binaries |
|
|
||||||
| `ytsage/utils/ytsage_config_manager.py` | atomic save, defaults merge, player/feed/advanced keys |
|
|
||||||
| `pyproject.toml` | name, version, deps (python-mpv), URLs |
|
|
||||||
| `ytsage/languages/en.json` | added sections: player, cards, main_tabs, search, watch, browse, feed |
|
|
||||||
|
|
||||||
Bug fixes made in this fork (see git log between the merge commit and the
|
|
||||||
first feature commit) may also conflict if upstream fixes them differently —
|
|
||||||
prefer whichever version is stricter about verification and thread safety.
|
|
||||||
|
|
||||||
## SageTube-only modules (never conflict)
|
|
||||||
|
|
||||||
- `ytsage/core/ytsage_client.py` — shared yt-dlp JSON invocation layer
|
|
||||||
- `ytsage/core/ytsage_mpv.py` — libmpv probe
|
|
||||||
- `ytsage/gui/ytsage_gui_player.py` — embedded mpv player
|
|
||||||
- `ytsage/gui/ytsage_gui_watch.py` / `_search.py` / `_browse.py` / `_feed.py` — tabs
|
|
||||||
- `ytsage/gui/ytsage_gui_router.py` / `_cards.py` — navigation + video cards
|
|
||||||
- `ytsage/utils/ytsage_library_manager.py` — subscriptions/history/queue DB
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
<div align="center">
|
|
||||||
|
|
||||||
<img src="branding/svg/ytsage-wordmark.svg" width="400" alt="ytsage-wordmark">
|
|
||||||
<img src="branding/screenshots/main.png" width="800" alt="YTSage Interface"/>
|
|
||||||
|
|
||||||
[](https://www.python.org/downloads/)
|
|
||||||
[](https://pepy.tech/project/ytsage)
|
|
||||||
[](https://github.com/oop7/YTSage/releases)
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
[](https://github.com/oop7/YTSage/releases)
|
|
||||||
[](https://github.com/oop7/YTSage/stargazers)
|
|
||||||
[](https://pypi.org/project/ytsage/)
|
|
||||||
[](https://github.com/sponsors/oop7)
|
|
||||||
|
|
||||||
**Modern YouTube downloader with a clean PySide6 interface.**
|
|
||||||
Download videos in any quality, extract audio, fetch subtitles, and more.
|
|
||||||
|
|
||||||
### 🌍 README Languages
|
|
||||||
|
|
||||||
English: [EN](README.md)
|
|
||||||
| Arabic: [AR](readme-translations/README.ar.md)
|
|
||||||
| German: [DE](readme-translations/README.de.md)
|
|
||||||
| Spanish: [ES](readme-translations/README.es.md)
|
|
||||||
| French: [FR](readme-translations/README.fr.md)
|
|
||||||
| Hindi: [HI](readme-translations/README.hi.md)
|
|
||||||
| Indonesian: [ID](readme-translations/README.id.md)
|
|
||||||
| Italian: [IT](readme-translations/README.it.md)
|
|
||||||
| Japanese: [JA](readme-translations/README.ja.md)
|
|
||||||
| Polish: [PL](readme-translations/README.pl.md)
|
|
||||||
| Portuguese: [PT](readme-translations/README.pt.md)
|
|
||||||
| Russian: [RU](readme-translations/README.ru.md)
|
|
||||||
| Turkish: [TR](readme-translations/README.tr.md)
|
|
||||||
| Chinese: [ZH](readme-translations/README.zh.md)
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="#installation">Installation</a> •
|
|
||||||
<a href="#features">Features</a> •
|
|
||||||
<a href="#usage">Usage</a> •
|
|
||||||
<a href="#screenshots">Screenshots</a> •
|
|
||||||
<a href="#troubleshooting">Troubleshooting</a> •
|
|
||||||
<a href="#sponsor">Sponsor</a> •
|
|
||||||
<a href="#contributing">Contributing</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<a id="why-ytsage"></a>
|
|
||||||
## ❓ Why YTSage?
|
|
||||||
|
|
||||||
YTSage is designed for users who want a **simple yet powerful YouTube downloader**. Unlike other tools, it offers:
|
|
||||||
|
|
||||||
- A modern and clean PySide6 interface
|
|
||||||
- One-click downloads for video, audio, and subtitles
|
|
||||||
- Advanced features like SponsorBlock, subtitle merging, and playlist selection
|
|
||||||
- Optional Generic Mode for sites supported by yt-dlp beyond YouTube
|
|
||||||
- Cross-platform support and easy installation
|
|
||||||
|
|
||||||
<a id="features"></a>
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
| Core Features | Advanced Features | Extra Features |
|
|
||||||
|-----------------------------------|-----------------------------------------|------------------------------------|
|
|
||||||
| 🎥 Format Table | 🚫 SponsorBlock Integration | 🎞️ FPS/HDR Display |
|
|
||||||
| 🎵 Audio Extraction | 📝 Subtitle Selection & Merging | 🔄 Auto Update yt-dlp |
|
|
||||||
| ✨ Simple UI | 💾 Save Description & Thumbnail | 🛠️ FFmpeg/yt-dlp/Deno Detection |
|
|
||||||
| 📋 Playlist Support & Selector | 🚀 Speed Limiter | ⚙️ Custom Commands |
|
|
||||||
| 📑 Chapter Integration | ✂️ Video Section Trimming | 🍪 Login with Cookies |
|
|
||||||
| 📜 Download History | 🔄 Version Channel Selection | 🌐 Proxy Support |
|
|
||||||
| 🎚️ Audio Format Conversion | 🎬 Video Format Settings | 🆙 Built-in Updater Tab |
|
|
||||||
| 🌍 Generic Mode | 🔊 Audio Normalization (EBU R128) | 🌍 Localized in 14 Languages |
|
|
||||||
| 💾 Playlist Export | ⚙️ Default Quality & Subtitles | |
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a id="installation"></a>
|
|
||||||
## 🚀 Installation
|
|
||||||
|
|
||||||
### ⚡ Quick Install (Recommended)
|
|
||||||
|
|
||||||
Install YTSage via PyPI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install ytsage
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🔄 Update existing installation</summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install --upgrade ytsage
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
Then launch the application:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ytsage
|
|
||||||
```
|
|
||||||
|
|
||||||
### 📦 Pre-built Executables
|
|
||||||
|
|
||||||
> [👉 Download Latest Release](https://github.com/oop7/YTSage/releases/latest)
|
|
||||||
|
|
||||||
#### 🪟 Windows
|
|
||||||
|
|
||||||
| Format | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
|  | Standard Installer |
|
|
||||||
|  | With FFmpeg Included |
|
|
||||||
|  | Portable version, no installation needed |
|
|
||||||
|  | Portable with FFmpeg, zipped |
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🛠️ Installation Steps</summary>
|
|
||||||
|
|
||||||
1. **EXE Installer (`.exe`)**: Double-click the file and follow the setup wizard.
|
|
||||||
2. **Portable Version (`.zip`)**: Extract the archive to your desired location and launch `ytsage.exe`.
|
|
||||||
3. **FFmpeg Included**: Choose versions with FFmpeg included if you don't have FFmpeg installed on your system.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
#### 🐧 Linux
|
|
||||||
|
|
||||||
| Format | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
|  | Debian Package |
|
|
||||||
|  | AppImage, Portable |
|
|
||||||
|  | RPM Package |
|
|
||||||
|  | Flatpak Bundle |
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🛠️ Installation Steps</summary>
|
|
||||||
|
|
||||||
- **DEB (`.deb`)**:
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i ytsage_*.deb
|
|
||||||
sudo apt-get install -f # Fix missing dependencies if needed
|
|
||||||
```
|
|
||||||
- **RPM (`.rpm`)**:
|
|
||||||
```bash
|
|
||||||
sudo rpm -i ytsage-*.rpm
|
|
||||||
```
|
|
||||||
- **AppImage (`.AppImage`)**:
|
|
||||||
```bash
|
|
||||||
chmod +x YTSage-*.AppImage
|
|
||||||
./YTSage-*.AppImage
|
|
||||||
```
|
|
||||||
- **Flatpak**: Follow instructions on Flathub or run:
|
|
||||||
```bash
|
|
||||||
flatpak install flathub io.github.oop7.ytsage
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
|
|
||||||
#### 🍎 macOS
|
|
||||||
|
|
||||||
| Format | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
|  | Zipped Application for Apple Silicon |
|
|
||||||
|  | Disk Image Installer for Apple Silicon |
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🛠️ Installation Steps</summary>
|
|
||||||
|
|
||||||
- **DMG Installer (`.dmg`)**: Double-click to mount, then drag `YTSage.app` to your Applications folder.
|
|
||||||
- **Application Archive (`.zip`)**: Extract the zip and move `YTSage.app` to your Applications folder.
|
|
||||||
|
|
||||||
*Note: If you encounter an "Application is damaged" error, see the macOS troubleshooting section below.*
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>💻 Manual Source Installation</summary>
|
|
||||||
|
|
||||||
### 1. Clone the repository
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/oop7/YTSage.git
|
|
||||||
cd YTSage
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Install dependencies
|
|
||||||
|
|
||||||
#### ⚡ Using uv
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv pip install .
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 📦 Or using standard pip
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install .
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Run the application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m ytsage.main
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<a id="screenshots"></a>
|
|
||||||
## 📸 Screenshots
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td><img src="branding/screenshots/Download-Settings.png" alt="Download Settings" width="400"/></td>
|
|
||||||
<td><img src="branding/screenshots/playlist.png" alt="Playlist Download" width="400"/></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><em>Download Settings</em></td>
|
|
||||||
<td align="center"><em>Playlist Download</em></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><img src="branding/screenshots/audio_format.png" alt="Audio Format Selection" width="400"/></td>
|
|
||||||
<td><img src="branding/screenshots/Custom-Option.png" alt="Custom Options" width="400"/></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center"><em>Audio Format</em></td>
|
|
||||||
<td align="center"><em>Custom Options</em></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a id="usage"></a>
|
|
||||||
## 📖 Usage
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🎯 Basic Usage</summary>
|
|
||||||
|
|
||||||
1. **Launch YTSage**
|
|
||||||
2. **Paste YouTube URL** (or use "Paste URL" button)
|
|
||||||
3. **Click "Analyze"**
|
|
||||||
4. **Select Format:**
|
|
||||||
- `Video` for video downloads
|
|
||||||
- `Audio Only` for audio extraction
|
|
||||||
5. **Choose Options:**
|
|
||||||
- Enable Subtitles and select language
|
|
||||||
- Enable Subtitle Merging
|
|
||||||
- Save Thumbnail
|
|
||||||
- Remove Sponsored Segments
|
|
||||||
- Save Description
|
|
||||||
- Embed Chapters
|
|
||||||
6. **Select Output Directory**
|
|
||||||
7. **Click "Download"**
|
|
||||||
|
|
||||||
> 💡 Default download directory is the user's "Downloads" folder.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>📋 Playlist Download</summary>
|
|
||||||
|
|
||||||
1. **Paste Playlist URL**
|
|
||||||
2. **Click "Analyze"**
|
|
||||||
3. **Select videos from the playlist selector (optional, defaults to all)**
|
|
||||||
4. **Choose desired format/quality**
|
|
||||||
5. **Click "Download"**
|
|
||||||
|
|
||||||
> 💡 The application automatically handles the download queue, and you can export playlist entries as `.txt`, `.csv`, `.m3u`, or `.json`.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🌍 Generic Mode for Non-YouTube Sites</summary>
|
|
||||||
|
|
||||||
Use Generic Mode when you want YTSage to accept URLs from sites supported by yt-dlp, such as Dailymotion, CBC Gem, TikTok, and others.
|
|
||||||
|
|
||||||
How to use it:
|
|
||||||
|
|
||||||
1. Open `Download Settings`.
|
|
||||||
2. Toggle on `Generic Mode`.
|
|
||||||
3. Paste a supported video or playlist URL that is not from YouTube.
|
|
||||||
4. Click `Analyze`.
|
|
||||||
5. Choose a format and download as usual.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- Generic mode only changes the URL validation inside YTSage. The target site must still be supported by your installed version of yt-dlp.
|
|
||||||
- Some sites require cookies, login sessions, proxy, or extra yt-dlp arguments depending on the extractor.
|
|
||||||
- If a site fails, update yt-dlp from the built-in updater tab first before reporting an issue.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🧰 Media & Download Options</summary>
|
|
||||||
|
|
||||||
- **Subtitle Options:** Filter languages and embed subtitles into the video file.
|
|
||||||
- **Subtitle Merging:** Merge subtitles into the video file for hardcoded/burned-in subtitles.
|
|
||||||
- **Save Description:** Save the video description as a text file.
|
|
||||||
- **Save Thumbnail:** Save the video thumbnail as an image file.
|
|
||||||
- **Embed Chapters:** Embed chapter markers as metadata for compatible video players.
|
|
||||||
- **Remove Sponsored Segments:** Remove sponsored segments from the video using SponsorBlock.
|
|
||||||
- **Trim Video:** Download only specific parts of a video by specifying time ranges in `HH:MM:SS` format.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>⚙️ Output & File Settings</summary>
|
|
||||||
|
|
||||||
- **Speed Limiter:** Limit download speed, e.g., `500K` for 500 KB/s.
|
|
||||||
- **Save Download Path:** Saves the default download path for future downloads. Available in **Download Settings → Download Path**.
|
|
||||||
- **Default Video Resolution:** Set your preferred default video resolution for auto-selection (e.g., 1080p, 720p). Available in **Download Settings → Default Video Resolution**.
|
|
||||||
- **Default Subtitle Languages:** Set default subtitle languages for auto-selection (comma-separated, e.g., `en,es`). Available in **Download Settings → Default Subtitle Languages**.
|
|
||||||
- **Output Filename Format:** Customize the output filename format using variables like `%(title)s`, `%(uploader)s`, `%(playlist_index)s`, and `%(resolution)s`. Available in **Download Settings → Filename Format**.
|
|
||||||
- **Force Output Format:** Force video downloads into a specific container format like `mp4`, `webm`, or `mkv`. Available in **Download Settings → Output Format Settings**.
|
|
||||||
- **Audio Format Conversion:** Convert audio-only downloads into preferred formats such as `AAC`, `MP3`, `FLAC`, `WAV`, `Opus`, `M4A`, `Vorbis`, or `Best`. Available in **Download Settings → Audio Format Settings**.
|
|
||||||
- **Audio Normalization:** Standardize volume for audio-only downloads using EBU R128.
|
|
||||||
- **Concurrent Connections:** Dramatically increase download speed by downloading files in multiple fragments simultaneously. Available in **Download Settings → General → Concurrent Connections** (Default is 1, maximum recommended is 8-10 to avoid IP throttling).
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🌐 Access & Network</summary>
|
|
||||||
|
|
||||||
- **Login with Cookies:** Log in to YouTube using cookies to access private content.
|
|
||||||
How to use it:
|
|
||||||
1. **Recommended:** Use the built-in `Extract cookies from browser` option in the app, then select your browser and optionally a profile.
|
|
||||||
2. Alternatively, extract cookies manually:
|
|
||||||
a. Export browser cookies using an extension like [cookie-editor](https://github.com/moustachauve/cookie-editor?tab=readme-ov-file)
|
|
||||||
b. Copy cookies in Netscape format
|
|
||||||
c. Create a file named `cookies.txt` and paste cookies
|
|
||||||
d. Select the `cookies.txt` file in the app
|
|
||||||
- **Proxy Support:** Use a proxy server for downloads, e.g., `http://<proxy-server>:<port>`
|
|
||||||
- **Generic Mode:** Allows YTSage to analyze and download from non-YouTube sites supported by yt-dlp. Enable from **Download Settings → Generic Mode**.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🛠️ Tools & Maintenance</summary>
|
|
||||||
|
|
||||||
- **Custom Commands:** Access advanced yt-dlp features via command-line arguments.
|
|
||||||
- **Updater Tab:** Manage built-in update tools from one place in Custom Options:
|
|
||||||
- **yt-dlp Updates:** Check for updates and toggle between Stable and Nightly release channels.
|
|
||||||
- **FFmpeg Version Checker:** Check your FFmpeg version and open installation guides.
|
|
||||||
- **Deno Updates:** Check and update the Deno runtime.
|
|
||||||
- **FFmpeg/yt-dlp/Deno Detection:** Automatically detects paths and versions for FFmpeg, yt-dlp, and Deno from the About dialog.
|
|
||||||
- **Download History:** View past downloads with thumbnails and statuses from the **History** button.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>🌍 Localization</summary>
|
|
||||||
|
|
||||||
YTSage supports **14 languages** for global accessibility. Select your preferred language in **Custom Options → Language**.
|
|
||||||
|
|
||||||
### Supported Languages
|
|
||||||
|
|
||||||
| Language | Code | Language | Code |
|
|
||||||
|----------|------|----------|------|
|
|
||||||
| 🇺🇸 English | `en` | 🇪🇸 Spanish | `es` |
|
|
||||||
| 🇸🇦 Arabic | `ar` | 🇫🇷 French | `fr` |
|
|
||||||
| 🇩🇪 German | `de` | 🇮🇳 Hindi | `hi` |
|
|
||||||
| 🇮🇩 Indonesian | `id` | 🇮🇹 Italian | `it` |
|
|
||||||
| 🇯🇵 Japanese | `ja` | 🇵🇱 Polish | `pl` |
|
|
||||||
| 🇧🇷 Portuguese | `pt` | 🇷🇺 Russian | `ru` |
|
|
||||||
| 🇹🇷 Turkish | `tr` | 🇨🇳 Chinese | `zh` |
|
|
||||||
|
|
||||||
### README Translations
|
|
||||||
|
|
||||||
| Language | File | Language | File |
|
|
||||||
|----------|------|----------|------|
|
|
||||||
| 🇺🇸 English | [README.md](README.md) | 🇪🇸 Spanish | [readme-translations/README.es.md](readme-translations/README.es.md) |
|
|
||||||
| 🇸🇦 Arabic | [readme-translations/README.ar.md](readme-translations/README.ar.md) | 🇫🇷 French | [readme-translations/README.fr.md](readme-translations/README.fr.md) |
|
|
||||||
| 🇩🇪 German | [readme-translations/README.de.md](readme-translations/README.de.md) | 🇮🇳 Hindi | [readme-translations/README.hi.md](readme-translations/README.hi.md) |
|
|
||||||
| 🇮🇩 Indonesian | [readme-translations/README.id.md](readme-translations/README.id.md) | 🇮🇹 Italian | [readme-translations/README.it.md](readme-translations/README.it.md) |
|
|
||||||
| 🇯🇵 Japanese | [readme-translations/README.ja.md](readme-translations/README.ja.md) | 🇵🇱 Polish | [readme-translations/README.pl.md](readme-translations/README.pl.md) |
|
|
||||||
| 🇧🇷 Portuguese | [readme-translations/README.pt.md](readme-translations/README.pt.md) | 🇷🇺 Russian | [readme-translations/README.ru.md](readme-translations/README.ru.md) |
|
|
||||||
| 🇹🇷 Turkish | [readme-translations/README.tr.md](readme-translations/README.tr.md) | 🇨🇳 Chinese | [readme-translations/README.zh.md](readme-translations/README.zh.md) |
|
|
||||||
|
|
||||||
> 💡 **Want to contribute a translation?** Check out the [Contributing](#contributing) section to help us add more languages!
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<a id="troubleshooting"></a>
|
|
||||||
## 🛠️ Troubleshooting
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Click to view common issues and solutions</summary>
|
|
||||||
|
|
||||||
- **Format table not appearing:** Update yt-dlp to latest version and switch to nightly yt-dlp.
|
|
||||||
- **Download failed:** Check your internet connection and ensure the video is available.
|
|
||||||
- **Specific Download Errors:**
|
|
||||||
- **Private Videos:** Use cookie authentication to access private content.
|
|
||||||
- **Age-Restricted Content:** Log in to your YouTube account to view age-restricted videos.
|
|
||||||
- **Geo-Blocked Videos:** Consider using a VPN to bypass regional restrictions.
|
|
||||||
- **Deleted Videos:** Video is no longer available on YouTube.
|
|
||||||
- **Live Streams:** Live streams cannot be downloaded; wait for the broadcast to end.
|
|
||||||
- **Network Errors:** Check your internet connection and try again.
|
|
||||||
- **Invalid URLs:** Ensure the URL is correct and from a supported platform.
|
|
||||||
- **Premium Content:** Requires a YouTube Premium subscription.
|
|
||||||
- **Copyright Blocks:** Content is blocked due to copyright restrictions.
|
|
||||||
- **Video and Audio Files separate after download:** This happens when FFmpeg is missing or not detected. YTSage requires FFmpeg to merge high-quality video and audio streams.
|
|
||||||
- **Solution:** Ensure FFmpeg is installed and accessible in your system's PATH. For Windows users, the easiest option is to download the `YTSage-v<version>-ffmpeg.exe` file, which comes bundled with FFmpeg.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🛡️ Windows Defender / Antivirus Warning
|
|
||||||
|
|
||||||
Some antivirus software may flag `.exe` files as false positives. This is a **known limitation** of packaged applications.
|
|
||||||
|
|
||||||
**Why this happens:**
|
|
||||||
- Antivirus heuristics can mistakenly identify packaged executables as suspicious.
|
|
||||||
|
|
||||||
**Safe Alternatives:**
|
|
||||||
- ✅ **Use pip install:** `pip install ytsage` (Recommended)
|
|
||||||
- ✅ **Build from Source**: by following this [guide](.github/CI_CD_README.md)
|
|
||||||
- ✅ **Whitelist the app** in your antivirus software.
|
|
||||||
|
|
||||||
#### 🍎 macOS: "Application is damaged and cannot be opened"
|
|
||||||
If you see this error on macOS Sonoma or newer, you need to remove the quarantine attribute.
|
|
||||||
|
|
||||||
1. **Open Terminal** (you can find this using Spotlight).
|
|
||||||
2. **Type the following command** but **do not** press Enter yet. Make sure to include the space at the end:
|
|
||||||
```bash
|
|
||||||
xattr -d com.apple.quarantine
|
|
||||||
```
|
|
||||||
3. **Drag the `YTSage.app` file** from your Finder window and drop it directly into the Terminal window. This will automatically paste the correct file path.
|
|
||||||
4. **Press Enter** to run the command.
|
|
||||||
5. **Try opening YTSage.app again.** It should now launch correctly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### **Config Locations (Advanced)**
|
|
||||||
- **Windows:** `%LOCALAPPDATA%\YTSage`
|
|
||||||
- **macOS:** `~/Library/Application Support/YTSage`
|
|
||||||
- **Linux:** `~/.local/share/YTSage`
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<a id="sponsor"></a>
|
|
||||||
## 💖 Sponsor
|
|
||||||
|
|
||||||
If YTSage saves you time, please consider sponsoring the project. Sponsoring helps cover development time, testing across all platforms, and future improvements.
|
|
||||||
|
|
||||||
- GitHub Sponsors: https://github.com/sponsors/oop7
|
|
||||||
- Sponsorship link is also available directly in the app via the About dialog.
|
|
||||||
|
|
||||||
[](https://github.com/sponsors/oop7)
|
|
||||||
|
|
||||||
<a id="contributing"></a>
|
|
||||||
## 👥 Contributing
|
|
||||||
|
|
||||||
We welcome contributions! Here’s how you can help:
|
|
||||||
|
|
||||||
1. 🍴 Fork the repository
|
|
||||||
2. 🌿 Create your feature branch:
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/AmazingFeature
|
|
||||||
```
|
|
||||||
3. 💾 Commit your changes:
|
|
||||||
```bash
|
|
||||||
git commit -m 'Add some AmazingFeature'
|
|
||||||
```
|
|
||||||
4. 📤 Push to the branch:
|
|
||||||
```bash
|
|
||||||
git push origin feature/AmazingFeature
|
|
||||||
```
|
|
||||||
5. 🔄 Open a Pull Request
|
|
||||||
|
|
||||||
### 🌍 Contributing Translations
|
|
||||||
|
|
||||||
- Update the relevant localized README file (e.g., `readme-translations/README.fr.md`)
|
|
||||||
- Keep app strings synced by editing `ytsage/languages/<code>.json`
|
|
||||||
- If your language is missing, start from `README.md` and create `readme-translations/README.<code>.md`
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>📂 Project Structure</summary>
|
|
||||||
|
|
||||||
## YTSage - Project Structure
|
|
||||||
|
|
||||||
This document describes the organized folder structure of YTSage.
|
|
||||||
|
|
||||||
### 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
YTSage/
|
|
||||||
├── 📁 .github/ # GitHub configuration
|
|
||||||
│ ├── 📁 ISSUE_TEMPLATE/ # Issue templates
|
|
||||||
│ │ └── 🐛-bug-report.md # Bug report template
|
|
||||||
│ ├─── 📁 workflows/ # GitHub Actions workflows
|
|
||||||
│ │ ├── build-linux.yml # Linux build workflow
|
|
||||||
│ │ ├── build-macos.yml # macOS build workflow
|
|
||||||
│ │ │── build-windows.yml # Windows build workflow
|
|
||||||
| | └── release-all.yml # Release master workflow
|
|
||||||
│ └── 📄 CI_CD_README.md # CI/CD documentation
|
|
||||||
├── 📁 branding/ # Branding assets (Screenshots, SVGs)
|
|
||||||
│ ├── 📁 icons/ # App icons
|
|
||||||
│ ├── 📁 screenshots/ # Documentation screenshots
|
|
||||||
│ └── 📁 svg/ # SVG assets
|
|
||||||
├── 📄 LICENSE # License file
|
|
||||||
├── 📄 pyproject.toml # Project metadata and dependencies
|
|
||||||
├── 📄 README.md # Project documentation
|
|
||||||
├── 📄 requirements.txt # Python dependencies (dev)
|
|
||||||
└── 📁 ytsage/ # Source package
|
|
||||||
├── 📁 assets/ # Runtime assets
|
|
||||||
│ ├── 📁 Icon/ # App icons
|
|
||||||
│ └── 📁 sound/ # Sound files
|
|
||||||
├── 📁 languages/ # Localization files
|
|
||||||
│ ├── 📄 ar.json # Arabic translation
|
|
||||||
│ ├── 📄 de.json # German translation
|
|
||||||
│ ├── 📄 en.json # English translation
|
|
||||||
│ └── ... # Other languages
|
|
||||||
├── 📁 core/ # Core business logic
|
|
||||||
│ ├── 📄 __init__.py # Core package init
|
|
||||||
│ ├── 📄 ytsage_deno.py # Deno integration
|
|
||||||
│ ├── 📄 ytsage_downloader.py # Download functionality
|
|
||||||
│ ├── 📄 ytsage_ffmpeg.py # FFmpeg integration
|
|
||||||
│ ├── 📄 ytsage_utils.py # Utility functions
|
|
||||||
│ └── 📄 ytsage_yt_dlp.py # yt-dlp integration
|
|
||||||
├── 📁 gui/ # UI components
|
|
||||||
│ ├── 📄 __init__.py # GUI package init
|
|
||||||
│ ├── 📄 ytsage_gui_main.py # Main app window
|
|
||||||
│ └── 📁 ytsage_gui_dialogs/ # Dialog classes
|
|
||||||
├── 📁 utils/ # Utility modules
|
|
||||||
│ ├── 📄 __init__.py # Utils package init
|
|
||||||
│ ├── 📄 ytsage_config_manager.py # Config management
|
|
||||||
│ └── 📄 ytsage_logger.py # Logging utilities
|
|
||||||
├── 📄 __init__.py # Package entry point
|
|
||||||
└── 📄 main.py # Main execution script
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## ⭐️ Star History
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
<a href="https://www.star-history.com/#oop7/YTSage&Date">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date&theme=dark" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oop7/YTSage&type=Date" />
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 📜 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Show Acknowledgments</summary>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
<p>A big thanks to everyone who contributed to this project by opening an issue to suggest an improvement or report a bug.</p>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<tr class="section"><th colspan="2">Core Components</th></tr>
|
|
||||||
<tr>
|
|
||||||
<td width="35%"><a href="https://github.com/yt-dlp/yt-dlp">yt-dlp</a></td>
|
|
||||||
<td>Download Engine</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://ffmpeg.org/">FFmpeg</a></td>
|
|
||||||
<td>Media Processing</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://deno.com/">Deno</a></td>
|
|
||||||
<td>Runtime for yt-dlp plugins</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="section"><th colspan="2">Libraries & Frameworks</th></tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://wiki.qt.io/Qt_for_Python">PySide6</a></td>
|
|
||||||
<td>GUI Framework</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://python-pillow.org/">Pillow</a></td>
|
|
||||||
<td>Image Processing</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://requests.readthedocs.io/">requests</a></td>
|
|
||||||
<td>HTTP Requests</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://packaging.python.org/">packaging</a></td>
|
|
||||||
<td>Version/Package Management</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://python-markdown.github.io/">markdown</a></td>
|
|
||||||
<td>Markdown Rendering</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://github.com/Delgan/loguru">loguru</a></td>
|
|
||||||
<td>Logging</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="section"><th colspan="2">Assets & Contributors</th></tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://pixabay.com/sound-effects/new-notification-09-352705/">New Notification 09 by Universfield</a></td>
|
|
||||||
<td>Notification Sound</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><a href="https://github.com/viru185">viru185</a></td>
|
|
||||||
<td>Code Contributor</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
|
||||||
|
|
||||||
This tool is for personal use only. Please respect YouTube's Terms of Service and content creator rights.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
Made with ❤️ by [oop7](https://github.com/oop7)
|
|
||||||
|
|
||||||
</div>
|
|
||||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sagetube"
|
name = "sagetube"
|
||||||
version = "0.1.0"
|
version = "5.5.2"
|
||||||
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
@@ -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.2"
|
||||||
__author__ = "oop7"
|
__author__ = "Houmeres"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -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())
|
||||||
@@ -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))
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
+234
-18
@@ -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)
|
||||||
@@ -102,7 +148,10 @@ class FeedPage(QWidget):
|
|||||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||||
layout.addWidget(splitter, stretch=1)
|
layout.addWidget(splitter, stretch=1)
|
||||||
|
|
||||||
side = QWidget()
|
# Kept as an attribute: account mode does not use local subscriptions,
|
||||||
|
# so the panel is hidden there rather than sitting empty beside a full
|
||||||
|
# feed and implying something is missing.
|
||||||
|
self.subs_panel = side = QWidget()
|
||||||
side_layout = QVBoxLayout(side)
|
side_layout = QVBoxLayout(side)
|
||||||
side_layout.setContentsMargins(0, 0, 4, 0)
|
side_layout.setContentsMargins(0, 0, 4, 0)
|
||||||
subs_label = QLabel(_("feed.subscriptions"))
|
subs_label = QLabel(_("feed.subscriptions"))
|
||||||
@@ -115,8 +164,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 +206,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 +224,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
|
||||||
subs = LibraryManager.subscriptions()
|
if subs is None:
|
||||||
|
subs = LibraryManager.subscriptions()
|
||||||
if not subs:
|
if not subs:
|
||||||
self.status_label.setText(_("feed.no_subscriptions"))
|
if not auto:
|
||||||
|
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 +294,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)
|
||||||
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
|
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._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 +334,51 @@ 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.
|
||||||
|
"""
|
||||||
|
account_mode = (self.mode_combo.currentData() or "local") == "account"
|
||||||
|
self.subs_panel.setVisible(not account_mode)
|
||||||
|
|
||||||
|
self.grid.empty_label.hide()
|
||||||
|
if self.grid.card_count() > 0:
|
||||||
|
self.empty_hint.hide()
|
||||||
|
self.grid.show()
|
||||||
|
return
|
||||||
|
|
||||||
|
# The message depends on the mode: account mode does not use local
|
||||||
|
# subscriptions at all, so telling someone to go and subscribe to
|
||||||
|
# something would be wrong advice.
|
||||||
|
if account_mode:
|
||||||
|
message = _("feed.empty_account")
|
||||||
|
elif not LibraryManager.subscriptions():
|
||||||
|
message = _("feed.empty_no_subscriptions")
|
||||||
|
else:
|
||||||
|
message = _("feed.empty_not_refreshed")
|
||||||
|
self.empty_hint.setText(message)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
@@ -223,10 +396,15 @@ class FeedPage(QWidget):
|
|||||||
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
|
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
|
||||||
self.grid.set_entries(entries)
|
self.grid.set_entries(entries)
|
||||||
self.status_label.setText(_("feed.refreshed", count=len(entries)))
|
self.status_label.setText(_("feed.refreshed", count=len(entries)))
|
||||||
|
# Without this the grid stays hidden behind the empty-state hint that
|
||||||
|
# was shown at startup, so a successful refresh reported "60 videos in
|
||||||
|
# feed" above a "No subscriptions yet" message and no videos.
|
||||||
|
self._update_empty_state()
|
||||||
|
|
||||||
def _on_account_error(self, message: str) -> None:
|
def _on_account_error(self, message: str) -> None:
|
||||||
logger.error(f"Account feed failed: {message}")
|
logger.error(f"Account feed failed: {message}")
|
||||||
self.status_label.setText(_("feed.account_failed", error=message[:200]))
|
self.status_label.setText(_("feed.account_failed", error=message[:200]))
|
||||||
|
self._update_empty_state()
|
||||||
|
|
||||||
def _on_account_finished(self) -> None:
|
def _on_account_finished(self) -> None:
|
||||||
self.refresh_btn.setEnabled(True)
|
self.refresh_btn.setEnabled(True)
|
||||||
@@ -242,15 +420,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)
|
||||||
|
|||||||
+235
-156
@@ -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,
|
||||||
@@ -32,9 +31,10 @@ from PySide6.QtWidgets import (
|
|||||||
QGraphicsScene,
|
QGraphicsScene,
|
||||||
QGraphicsPixmapItem,
|
QGraphicsPixmapItem,
|
||||||
)
|
)
|
||||||
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor
|
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor, QShortcut, QKeySequence
|
||||||
|
|
||||||
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.
|
||||||
update_available = Signal(str, str, str) # version, url, changelog
|
|
||||||
|
|
||||||
# Reduced timeouts for faster failure detection
|
This used to compare against PyPI's `ytsage` package and link to
|
||||||
PYPI_TIMEOUT = 8
|
oop7/YTSage's releases -- upstream's release stream, not this program's.
|
||||||
GITHUB_TIMEOUT = 5
|
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
|
||||||
|
|
||||||
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"))
|
||||||
|
)
|
||||||
if check_beta:
|
app_update.mark_checked()
|
||||||
latest_ver_str, tag, changelog = self._fetch_github_beta_version()
|
|
||||||
|
if release is None:
|
||||||
if latest_ver_str and version.parse(latest_ver_str) > version.parse(self.current_version):
|
logger.debug("Update check: nothing newer published.")
|
||||||
release_url = f"https://github.com/oop7/YTSage/releases/tag/{tag}"
|
return
|
||||||
if not changelog:
|
if not app_update.is_newer(release, self.current_version):
|
||||||
changelog = "View the full changelog on GitHub."
|
logger.info(f"Update check: {self.current_version} is current (latest {release.tag}).")
|
||||||
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
|
||||||
@@ -310,6 +230,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to restore state: {e}")
|
logger.debug(f"Failed to restore state: {e}")
|
||||||
|
|
||||||
|
# Never start fullscreen. restoreGeometry() replays the window state
|
||||||
|
# it was saved with, and since fullscreen became a state of the *main*
|
||||||
|
# window rather than a separate one, quitting from fullscreen used to
|
||||||
|
# bring the app back with no title bar, no close button, and no way
|
||||||
|
# out -- fullscreen is driven from the Watch tab, which may not even be
|
||||||
|
# the visible tab at startup. Maximised is kept: it is escapable.
|
||||||
|
if self.windowState() & Qt.WindowState.WindowFullScreen:
|
||||||
|
logger.info("Ignoring saved fullscreen state; starting windowed.")
|
||||||
|
self.setWindowState(self.windowState() & ~Qt.WindowState.WindowFullScreen)
|
||||||
|
|
||||||
def _perform_startup_checks(self):
|
def _perform_startup_checks(self):
|
||||||
"""Perform potentially blocking startup checks after UI is shown."""
|
"""Perform potentially blocking startup checks after UI is shown."""
|
||||||
|
|
||||||
@@ -409,6 +339,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 +461,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 +577,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 +587,103 @@ 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._install_fullscreen_shortcuts()
|
||||||
|
|
||||||
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 _install_fullscreen_shortcuts(self) -> None:
|
||||||
|
"""
|
||||||
|
Window-level F11 and Escape.
|
||||||
|
|
||||||
|
The player's own F and Escape are scoped to the player, so they only
|
||||||
|
fire while it has focus. Fullscreen now belongs to the whole window, so
|
||||||
|
there must be a way out of it from anywhere -- otherwise a window with
|
||||||
|
no title bar and no close button has no exit at all.
|
||||||
|
"""
|
||||||
|
self._fullscreen_shortcuts = []
|
||||||
|
for sequence, handler in (
|
||||||
|
("F11", self._toggle_fullscreen_from_window),
|
||||||
|
("Escape", self._escape_fullscreen),
|
||||||
|
):
|
||||||
|
shortcut = QShortcut(QKeySequence(sequence), self)
|
||||||
|
shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
|
||||||
|
shortcut.activated.connect(handler)
|
||||||
|
self._fullscreen_shortcuts.append(shortcut)
|
||||||
|
|
||||||
|
def _toggle_fullscreen_from_window(self) -> None:
|
||||||
|
controller = getattr(self, "fullscreen_controller", None)
|
||||||
|
if controller is None:
|
||||||
|
# No player, so no controller -- still honour F11 as a plain
|
||||||
|
# window toggle rather than doing nothing.
|
||||||
|
self.showNormal() if self.isFullScreen() else self.showFullScreen()
|
||||||
|
return
|
||||||
|
controller.toggle()
|
||||||
|
|
||||||
|
def _escape_fullscreen(self) -> None:
|
||||||
|
"""Escape leaves fullscreen and is otherwise ignored."""
|
||||||
|
controller = getattr(self, "fullscreen_controller", None)
|
||||||
|
if controller is not None and controller.is_active():
|
||||||
|
controller.exit()
|
||||||
|
elif self.isFullScreen():
|
||||||
|
self.showNormal()
|
||||||
|
|
||||||
|
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 +1111,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 +1208,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 +1224,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 +1296,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 +1312,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...")
|
||||||
@@ -1284,7 +1341,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
|||||||
self.current_download.terminate()
|
self.current_download.terminate()
|
||||||
self.current_download.wait(1000) # Wait for termination
|
self.current_download.wait(1000) # Wait for termination
|
||||||
|
|
||||||
# Save the window size and state
|
# Save the window size and state. Leave fullscreen first, so what
|
||||||
|
# gets stored is the windowed geometry -- saving while fullscreen
|
||||||
|
# records a size covering the whole screen, which is not a useful
|
||||||
|
# window to come back to.
|
||||||
|
try:
|
||||||
|
if getattr(self, "fullscreen_controller", None) is not None and self.fullscreen_controller.is_active():
|
||||||
|
self.fullscreen_controller.exit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not leave fullscreen before saving geometry: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ConfigManager.set("window_geometry", self.saveGeometry().toBase64().data().decode("ascii"))
|
ConfigManager.set("window_geometry", self.saveGeometry().toBase64().data().decode("ascii"))
|
||||||
ConfigManager.set("window_state", self.saveState().toBase64().data().decode("ascii"))
|
ConfigManager.set("window_state", self.saveState().toBase64().data().decode("ascii"))
|
||||||
@@ -1337,6 +1403,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 +1786,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 +2007,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)
|
||||||
|
overlay = QLabel(self)
|
||||||
# 3. Create an overlay widget that covers the Main Window
|
overlay.setPixmap(blurred_pixmap)
|
||||||
overlay = QLabel(self)
|
|
||||||
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)
|
||||||
|
|||||||
+675
-64
@@ -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,45 +349,170 @@ 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:
|
||||||
flip_y=True,
|
return
|
||||||
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
|
try:
|
||||||
)
|
render_ctx.render(
|
||||||
|
flip_y=True,
|
||||||
|
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"mpv render failed: {e}")
|
||||||
|
|
||||||
|
def resizeGL(self, w: int, h: int) -> None:
|
||||||
|
# The FBO size travels with every render call, so nothing needs
|
||||||
|
# resizing here -- but a resize while paused must still repaint, or
|
||||||
|
# the last frame stays stretched to the old geometry.
|
||||||
|
self.update()
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
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}")
|
||||||
try:
|
|
||||||
if self._mpv is not None:
|
mpv_inst, self._mpv = self._mpv, None
|
||||||
self._mpv.terminate()
|
if mpv_inst is not None:
|
||||||
self._mpv = None
|
try:
|
||||||
except Exception as e:
|
mpv_inst.terminate()
|
||||||
logger.debug(f"Error terminating mpv: {e}")
|
except Exception as e:
|
||||||
|
logger.debug(f"Error terminating mpv: {e}")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- mouse
|
||||||
|
|
||||||
|
def mousePressEvent(self, event) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||||
|
self._click_timer.start(QApplication.doubleClickInterval())
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event) -> None:
|
||||||
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
|
self._click_timer.stop() # cancel the pending single-click
|
||||||
|
self.doubleClicked.emit()
|
||||||
|
super().mouseDoubleClickEvent(event)
|
||||||
|
|
||||||
|
def wheelEvent(self, event) -> None:
|
||||||
|
# Accumulate: a high-resolution trackpad sends far less than one
|
||||||
|
# notch (120 units) per event, and dropping those makes it inert.
|
||||||
|
self._wheel_remainder += event.angleDelta().y()
|
||||||
|
notches, self._wheel_remainder = divmod(abs(self._wheel_remainder), 120)
|
||||||
|
if notches:
|
||||||
|
sign = 1 if event.angleDelta().y() > 0 else -1
|
||||||
|
ctrl = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)
|
||||||
|
self.wheelScrolled.emit(sign * int(notches), ctrl)
|
||||||
|
event.accept()
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event) -> None:
|
||||||
|
self.mouseMovedOverVideo.emit()
|
||||||
|
super().mouseMoveEvent(event)
|
||||||
|
|
||||||
# ------------------------------------------------------------- controls
|
# ------------------------------------------------------------- controls
|
||||||
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
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:
|
||||||
|
# Trust the window, not just the flag. If something else put the
|
||||||
|
# window into fullscreen -- a restored geometry, the window manager,
|
||||||
|
# the user's own shortcut -- toggling must get *out* of it rather than
|
||||||
|
# trying to enter a state it is already in and appearing to do nothing.
|
||||||
|
if self._active or self._window.isFullScreen():
|
||||||
|
self.exit()
|
||||||
|
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:
|
||||||
|
# Also runs when the controller was never active but the window is
|
||||||
|
# fullscreen anyway, so restoring the chrome is unconditional.
|
||||||
|
if not self._active and self._prev_window_state is None and not self._window.isFullScreen():
|
||||||
|
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:
|
||||||
|
# Never restore *into* fullscreen: that is what we are leaving.
|
||||||
|
self._window.setWindowState(self._prev_window_state & ~Qt.WindowState.WindowFullScreen)
|
||||||
|
else:
|
||||||
|
self._window.showNormal()
|
||||||
|
if self._window.isFullScreen():
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -26,12 +35,21 @@ class FadingStackedWidget(QStackedWidget):
|
|||||||
|
|
||||||
widget = self.widget(index)
|
widget = self.widget(index)
|
||||||
curr_widget = self.widget(curr_index)
|
curr_widget = self.widget(curr_index)
|
||||||
|
|
||||||
# If widget isn't visible or valid, just swap
|
# If widget isn't visible or valid, just swap
|
||||||
if not self.isVisible() or not curr_widget:
|
if not self.isVisible() or not curr_widget:
|
||||||
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,20 +95,38 @@ 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)
|
||||||
|
|
||||||
# Main Layout
|
# Main Layout
|
||||||
self.layout = QVBoxLayout(self)
|
self.layout = QVBoxLayout(self)
|
||||||
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
|
||||||
self.tab_bar.setDrawBase(False) # We draw border on content instead
|
# 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.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)
|
||||||
self.content_frame.setObjectName("tabContent")
|
self.content_frame.setObjectName("tabContent")
|
||||||
@@ -106,18 +142,70 @@ 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)
|
||||||
self.tab_bar.addTab(label)
|
if icon is not None:
|
||||||
|
self.tab_bar.addTab(icon, label)
|
||||||
|
else:
|
||||||
|
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."""
|
||||||
self.tab_bar.setCurrentIndex(index)
|
if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index:
|
||||||
self.stack.setCurrentIndex(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.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()
|
||||||
|
|
||||||
def currentIndex(self):
|
def currentIndex(self):
|
||||||
return self.stack.currentIndex()
|
return self.stack.currentIndex()
|
||||||
|
|
||||||
|
# --- QTabWidget-compatible API ---------------------------------------
|
||||||
|
# This class claims to "behave like a QTabWidget" and is used as a drop-in
|
||||||
|
# for one (CustomOptionsDialog builds its tabs on it), but it only ever
|
||||||
|
# offered set_current_index. Anything calling the Qt spelling raised
|
||||||
|
# AttributeError -- which is why "Sign in with cookies" appeared to do
|
||||||
|
# nothing: show_cookie_login_dialog selects the Cookies tab by index
|
||||||
|
# before showing the dialog, and died on that line.
|
||||||
|
|
||||||
|
def setCurrentIndex(self, index):
|
||||||
|
self.set_current_index(index)
|
||||||
|
|
||||||
|
def count(self):
|
||||||
|
return self.stack.count()
|
||||||
|
|
||||||
|
def widget(self, index):
|
||||||
|
return self.stack.widget(index)
|
||||||
|
|
||||||
|
def indexOf(self, widget):
|
||||||
|
return self.stack.indexOf(widget)
|
||||||
|
|
||||||
|
def tabText(self, index):
|
||||||
|
return self.tab_bar.tabText(index)
|
||||||
|
|
||||||
|
def setTabText(self, index, text):
|
||||||
|
self.tab_bar.setTabText(index, text)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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}; }}
|
||||||
|
"""
|
||||||
@@ -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.")
|
||||||
+76
-10
@@ -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,23 @@
|
|||||||
"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",
|
||||||
|
"empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed."
|
||||||
|
},
|
||||||
|
"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
@@ -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")
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
"""
|
||||||
|
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 "
|
||||||
|
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
|
||||||
|
)
|
||||||
|
params: List[Any] = []
|
||||||
|
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:
|
with cls._lock:
|
||||||
rows = cls._conn().execute(
|
rows = cls._conn().execute(query, tuple(params)).fetchall()
|
||||||
"SELECT f.*, s.title AS channel FROM feed_items f "
|
|
||||||
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
|
|
||||||
"ORDER BY COALESCE(f.published_ts, f.fetched_at) DESC LIMIT ?",
|
|
||||||
(limit,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
# ------------------------------------------------------- watch history
|
# ------------------------------------------------------- watch history
|
||||||
|
|||||||
@@ -74,10 +74,149 @@ 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",
|
||||||
|
"empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed."
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user