Compare commits
11 Commits
b3772ff484
...
v5.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
996d581a07
|
|||
|
ba05a4a7ef
|
|||
|
b07a56082a
|
|||
|
9c4749e49b
|
|||
|
fd744a1a85
|
|||
|
79a6f26c2f
|
|||
|
27933dd495
|
|||
|
3aca43b372
|
|||
|
5ae2817eea
|
|||
|
2420091a4e
|
|||
|
089b144626
|
+208
-1
@@ -8,7 +8,214 @@ Kept as the work happens, not assembled at release time. Format is
|
||||
with its history. SageTube's own versions start below and are the ones this file
|
||||
records.
|
||||
|
||||
## Unreleased
|
||||
## 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
|
||||
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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."
|
||||
authors = [
|
||||
{ name = "Houmeres", email = "admin@ecoposta.sk" },
|
||||
@@ -66,6 +66,7 @@ include = ["ytsage*"]
|
||||
[tool.setuptools.package-data]
|
||||
ytsage = [
|
||||
"assets/Icon/icon.png",
|
||||
"assets/Icon/icon-256.png",
|
||||
"assets/sound/notification.mp3",
|
||||
"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"
|
||||
__author__ = "oop7"
|
||||
__version__ = "5.5.2"
|
||||
__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 ..core.ytsage_client import YtdlpWorker
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
@@ -132,6 +133,7 @@ class BrowsePage(QWidget):
|
||||
self.url_input.setMinimumHeight(38)
|
||||
bar.addWidget(self.url_input, stretch=1)
|
||||
self.open_btn = QPushButton(_("browse.open"))
|
||||
self.open_btn.setToolTip(_("browse.open_tooltip"))
|
||||
self.open_btn.setMinimumHeight(38)
|
||||
self.open_btn.clicked.connect(self._open_from_input)
|
||||
bar.addWidget(self.open_btn)
|
||||
@@ -142,14 +144,17 @@ class BrowsePage(QWidget):
|
||||
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
|
||||
header.addWidget(self.title_label, stretch=1)
|
||||
self.subscribe_btn = QPushButton(_("browse.subscribe"))
|
||||
self.subscribe_btn.setToolTip(_("browse.subscribe_tooltip"))
|
||||
self.subscribe_btn.setVisible(False)
|
||||
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
|
||||
header.addWidget(self.subscribe_btn)
|
||||
self.playall_btn = QPushButton(_("browse.play_all"))
|
||||
self.playall_btn.setToolTip(_("browse.play_all_tooltip"))
|
||||
self.playall_btn.setVisible(False)
|
||||
self.playall_btn.clicked.connect(self._on_play_all)
|
||||
header.addWidget(self.playall_btn)
|
||||
self.download_btn = QPushButton(_("browse.download_playlist"))
|
||||
self.download_btn.setToolTip(_("browse.download_playlist_tooltip"))
|
||||
self.download_btn.setVisible(False)
|
||||
self.download_btn.clicked.connect(self._on_download_playlist)
|
||||
header.addWidget(self.download_btn)
|
||||
@@ -235,6 +240,9 @@ class BrowsePage(QWidget):
|
||||
}
|
||||
if self._channel_meta.get("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:
|
||||
if self._meta_worker is not None:
|
||||
@@ -246,13 +254,30 @@ class BrowsePage(QWidget):
|
||||
if not meta.get("title"):
|
||||
meta["title"] = self.title_label.text()
|
||||
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:
|
||||
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)
|
||||
if not e.get("url") and e.get("id"):
|
||||
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
|
||||
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:
|
||||
if self._current_url:
|
||||
|
||||
@@ -147,14 +147,17 @@ class VideoCard(QFrame):
|
||||
actions.setSpacing(6)
|
||||
|
||||
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()))
|
||||
actions.addWidget(self.play_btn)
|
||||
|
||||
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()))
|
||||
actions.addWidget(self.queue_btn)
|
||||
|
||||
self.download_btn = QPushButton(_("cards.download"))
|
||||
self.download_btn.setToolTip(_("cards.download_tooltip"))
|
||||
self.download_btn.clicked.connect(self._emit_download)
|
||||
actions.addWidget(self.download_btn)
|
||||
|
||||
@@ -204,6 +207,9 @@ class VideoCardGrid(QScrollArea):
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
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.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
@@ -220,6 +226,7 @@ class VideoCardGrid(QScrollArea):
|
||||
outer.addWidget(self._grid_widget)
|
||||
|
||||
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.setVisible(False)
|
||||
outer.addWidget(self.load_more_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -242,15 +249,56 @@ class VideoCardGrid(QScrollArea):
|
||||
for entry in entries:
|
||||
card = VideoCard(entry, self._router, self._grid_widget)
|
||||
self._cards.append(card)
|
||||
self._by_key[self._key(entry)] = card
|
||||
self._relayout()
|
||||
self.load_more_btn.setVisible(show_load_more)
|
||||
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:
|
||||
for card in self._cards:
|
||||
self._grid.removeWidget(card)
|
||||
card.deleteLater()
|
||||
self._cards = []
|
||||
self._by_key.clear()
|
||||
self.empty_label.setVisible(True)
|
||||
self.load_more_btn.setVisible(False)
|
||||
|
||||
@@ -265,10 +313,19 @@ class VideoCardGrid(QScrollArea):
|
||||
|
||||
def _relayout(self) -> None:
|
||||
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):
|
||||
self._grid.addWidget(card, i // cols, i % cols)
|
||||
self._columns_in_use = cols
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
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()
|
||||
|
||||
@@ -203,7 +203,7 @@ class AboutDialog(QDialog):
|
||||
|
||||
# Title and Version - more compact
|
||||
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)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
@@ -688,9 +688,11 @@ class UpdaterTabWidget(QWidget):
|
||||
beta_enabled = ConfigManager.get("check_beta_updates") or False
|
||||
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")
|
||||
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
|
||||
current_frequency = auto_settings["frequency"]
|
||||
|
||||
+234
-18
@@ -16,7 +16,7 @@ Browse page's Subscribe button routes here via the main window.
|
||||
import time
|
||||
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 (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
@@ -38,6 +38,18 @@ from ..utils.ytsage_localization import _
|
||||
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):
|
||||
"""Sequentially refresh each subscription's recent uploads."""
|
||||
|
||||
@@ -45,10 +57,17 @@ class FeedRefreshWorker(QThread):
|
||||
channelFailed = Signal(str, str) # channel_id, error
|
||||
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)
|
||||
self._subs = subscriptions
|
||||
self._per_channel = per_channel
|
||||
self._timeout = timeout
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
@@ -60,8 +79,15 @@ class FeedRefreshWorker(QThread):
|
||||
if self._cancelled:
|
||||
break
|
||||
try:
|
||||
kwargs = {}
|
||||
if self._timeout is not None:
|
||||
kwargs["timeout"] = self._timeout
|
||||
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.mark_refreshed(sub["channel_id"])
|
||||
@@ -73,17 +99,27 @@ class FeedRefreshWorker(QThread):
|
||||
|
||||
|
||||
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:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._refresh_worker: Optional[FeedRefreshWorker] = 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.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
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_account"), "account")
|
||||
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)
|
||||
|
||||
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)
|
||||
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.setStyleSheet("color: #9aa0a6;")
|
||||
bar.addWidget(self.status_label, stretch=1)
|
||||
@@ -102,7 +148,10 @@ class FeedPage(QWidget):
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
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.setContentsMargins(0, 0, 4, 0)
|
||||
subs_label = QLabel(_("feed.subscriptions"))
|
||||
@@ -115,8 +164,25 @@ class FeedPage(QWidget):
|
||||
side_layout.addWidget(self.subs_list)
|
||||
splitter.addWidget(side)
|
||||
|
||||
self.grid = VideoCardGrid(router, self)
|
||||
splitter.addWidget(self.grid)
|
||||
grid_host = QWidget()
|
||||
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(1, 4)
|
||||
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"))
|
||||
self.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id))
|
||||
self.reload_subscriptions()
|
||||
self._update_empty_state()
|
||||
self.subscriptionsChanged.emit()
|
||||
|
||||
def reload_subscriptions(self) -> None:
|
||||
self.subs_list.clear()
|
||||
@@ -156,21 +224,68 @@ class FeedPage(QWidget):
|
||||
else:
|
||||
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
|
||||
|
||||
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:
|
||||
return
|
||||
subs = LibraryManager.subscriptions()
|
||||
if subs is None:
|
||||
subs = LibraryManager.subscriptions()
|
||||
if not subs:
|
||||
self.status_label.setText(_("feed.no_subscriptions"))
|
||||
if not auto:
|
||||
self.status_label.setText(_("feed.no_subscriptions"))
|
||||
return
|
||||
per_channel = int(ConfigManager.get("feed.per_channel_items") or 15)
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.status_label.setText(_("feed.refreshing", done=0, total=len(subs)))
|
||||
self._done_count = 0
|
||||
self._failed_count = 0
|
||||
self._last_error = ""
|
||||
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.channelFailed.connect(self._on_channel_failed)
|
||||
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:
|
||||
self._done_count += 1
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
self._refresh_worker.deleteLater()
|
||||
self._refresh_worker = None
|
||||
|
||||
def _load_cached_feed(self) -> None:
|
||||
items = LibraryManager.feed_items()
|
||||
entries = [
|
||||
@staticmethod
|
||||
def _to_entries(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": it["video_id"],
|
||||
"url": it["url"],
|
||||
@@ -205,7 +334,51 @@ class FeedPage(QWidget):
|
||||
}
|
||||
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
|
||||
|
||||
@@ -223,10 +396,15 @@ class FeedPage(QWidget):
|
||||
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
|
||||
self.grid.set_entries(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:
|
||||
logger.error(f"Account feed failed: {message}")
|
||||
self.status_label.setText(_("feed.account_failed", error=message[:200]))
|
||||
self._update_empty_state()
|
||||
|
||||
def _on_account_finished(self) -> None:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
@@ -242,15 +420,53 @@ class FeedPage(QWidget):
|
||||
model_item = self.mode_combo.model().item(account_index)
|
||||
if model_item is not None:
|
||||
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":
|
||||
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:
|
||||
self._update_mode_availability()
|
||||
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:
|
||||
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:
|
||||
sub = item.data(Qt.ItemDataRole.UserRole)
|
||||
|
||||
+235
-156
@@ -5,11 +5,10 @@ import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
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.QtGui import QIcon
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||
from PySide6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
@@ -32,9 +31,10 @@ from PySide6.QtWidgets import (
|
||||
QGraphicsScene,
|
||||
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 ..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_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
|
||||
@@ -56,6 +56,7 @@ from .ytsage_gui_analysis import AnalysisMixin
|
||||
from .ytsage_smooth_tab_widget import SmoothTabWidget
|
||||
from ..utils.ytsage_constants import (
|
||||
ICON_PATH,
|
||||
ICON_PATH_LARGE,
|
||||
SOUND_PATH,
|
||||
SUBPROCESS_CREATIONFLAGS,
|
||||
VIDEO_EXTENSIONS,
|
||||
@@ -67,142 +68,50 @@ from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_localization import LocalizationManager, _
|
||||
from ..utils.ytsage_history_manager import HistoryManager
|
||||
from .ytsage_stylesheet import StyleSheet
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from .ytsage_theme import build_extra_qss
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
|
||||
|
||||
class UpdateCheckThread(QThread):
|
||||
"""Background thread for checking application updates with parallel network requests."""
|
||||
|
||||
update_available = Signal(str, str, str) # version, url, changelog
|
||||
"""
|
||||
Background check for a newer **SageTube** release.
|
||||
|
||||
# Reduced timeouts for faster failure detection
|
||||
PYPI_TIMEOUT = 8
|
||||
GITHUB_TIMEOUT = 5
|
||||
This used to compare against PyPI's `ytsage` package and link to
|
||||
oop7/YTSage's releases -- upstream's release stream, not this program's.
|
||||
Combined with a package version that had been left at 0.1.0, it reported an
|
||||
update on every start and sent the user to another project's downloads.
|
||||
|
||||
The forge query lives in core/ytsage_app_update.py. This class is only the
|
||||
thread wrapper, and keeps its name and `update_available` signature so the
|
||||
dialog and the caller are untouched.
|
||||
"""
|
||||
|
||||
update_available = Signal(str, str, str) # version, url, changelog
|
||||
|
||||
def __init__(self, current_version):
|
||||
super().__init__()
|
||||
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):
|
||||
"""Check for updates using parallel network requests for better performance."""
|
||||
try:
|
||||
# Check for beta updates if enabled
|
||||
check_beta = ConfigManager.get("check_beta_updates")
|
||||
|
||||
if check_beta:
|
||||
latest_ver_str, tag, changelog = self._fetch_github_beta_version()
|
||||
|
||||
if latest_ver_str and version.parse(latest_ver_str) > version.parse(self.current_version):
|
||||
release_url = f"https://github.com/oop7/YTSage/releases/tag/{tag}"
|
||||
if not changelog:
|
||||
changelog = "View the full changelog on GitHub."
|
||||
self.update_available.emit(latest_ver_str, release_url, changelog)
|
||||
# Return if beta check completes (whether update found or not),
|
||||
# effectively skipping PyPI check if beta is enabled.
|
||||
# This ensures we don't downgrade or conflict.
|
||||
release = app_update.fetch_latest(
|
||||
include_prerelease=bool(ConfigManager.get("check_beta_updates"))
|
||||
)
|
||||
app_update.mark_checked()
|
||||
|
||||
if release is None:
|
||||
logger.debug("Update check: nothing newer published.")
|
||||
return
|
||||
if not app_update.is_newer(release, self.current_version):
|
||||
logger.info(f"Update check: {self.current_version} is current (latest {release.tag}).")
|
||||
return
|
||||
|
||||
# Use ThreadPoolExecutor to make both requests in parallel
|
||||
# This reduces total wait time from potentially 15s to ~8s max
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
# 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)
|
||||
|
||||
changelog = release.body or _("update_dialog.changelog_unavailable")
|
||||
logger.info(f"Update available: {release.tag}")
|
||||
self.update_available.emit(str(release.version), release.url, changelog)
|
||||
except Exception as e:
|
||||
# An update check is never worth taking the app down for.
|
||||
logger.debug(f"Failed to check for updates: {e}")
|
||||
|
||||
|
||||
@@ -217,12 +126,18 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
self.version = APP_VERSION
|
||||
load_saved_path(self)
|
||||
# Load custom icon
|
||||
if ICON_PATH.exists():
|
||||
self.setWindowIcon(QIcon(str(ICON_PATH)))
|
||||
else:
|
||||
logger.warning(f"Icon file not found at {ICON_PATH}. Using default icon.")
|
||||
self.setWindowIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) # Fallback
|
||||
# Load custom icon. Both sizes go in so the window manager and taskbar
|
||||
# pick rather than upscale the 48px one.
|
||||
app_icon = QIcon()
|
||||
for path in (ICON_PATH, ICON_PATH_LARGE):
|
||||
if path.exists():
|
||||
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.download_paused = False
|
||||
self.current_download = None
|
||||
@@ -279,7 +194,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
# Defer heavy start-up tasks to ensure UI renders immediately
|
||||
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)
|
||||
|
||||
# After adding format buttons
|
||||
@@ -310,6 +230,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
except Exception as 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):
|
||||
"""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
|
||||
self.analyze_button = QPushButton(_("buttons.analyze"))
|
||||
self.analyze_button.setProperty("sageIcon", "search")
|
||||
self.analyze_button.clicked.connect(self.analyze_url)
|
||||
self.analyze_button.setEnabled(False) # Disabled until URL is entered
|
||||
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
|
||||
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.about_btn = QPushButton(_("buttons.about"))
|
||||
self.about_btn.setProperty("sageIcon", "info")
|
||||
self.about_btn.clicked.connect(self.show_about_dialog)
|
||||
|
||||
self.history_btn = QPushButton(_("buttons.history"))
|
||||
self.history_btn.setProperty("sageIcon", "clock")
|
||||
self.history_btn.clicked.connect(self.show_history_dialog)
|
||||
|
||||
# Add new Time Range button
|
||||
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)
|
||||
|
||||
# --- Rename Path Button to Settings 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._update_settings_tooltip()
|
||||
# --- End Settings Button ---
|
||||
|
||||
self.download_btn = QPushButton(_("buttons.download"))
|
||||
self.download_btn.setProperty("sageIcon", "download")
|
||||
self.download_btn.clicked.connect(self.start_download)
|
||||
|
||||
# Add pause and cancel buttons
|
||||
self.pause_btn = QPushButton(_("buttons.pause"))
|
||||
self.pause_btn.setProperty("sageIcon", "pause")
|
||||
self.pause_btn.clicked.connect(self.toggle_pause)
|
||||
self.pause_btn.setVisible(False) # Hidden initially
|
||||
|
||||
self.cancel_btn = QPushButton(_("buttons.cancel"))
|
||||
self.cancel_btn.setProperty("sageIcon", "x")
|
||||
self.cancel_btn.clicked.connect(self.cancel_download)
|
||||
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_search import SearchPage
|
||||
from .ytsage_gui_watch import WatchPage
|
||||
from .ytsage_gui_account import AccountStatusButton
|
||||
from .ytsage_player_fullscreen import FullscreenController
|
||||
|
||||
self.router = AppRouter(self)
|
||||
|
||||
@@ -646,20 +587,103 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self.feed_page = FeedPage(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.addTab(self.watch_page, _("main_tabs.watch"))
|
||||
self.main_tabs.addTab(self.search_page, _("main_tabs.search"))
|
||||
self.main_tabs.addTab(self.feed_page, _("main_tabs.feed"))
|
||||
self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"))
|
||||
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"))
|
||||
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"), icons.icon("search", theme.ICON))
|
||||
self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"), icons.icon("library", theme.ICON))
|
||||
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"), icons.icon("download", theme.ICON))
|
||||
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)
|
||||
|
||||
# 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.queueVideo.connect(self.watch_page.enqueue)
|
||||
self.router.downloadVideo.connect(self._route_download_video)
|
||||
self.router.openChannel.connect(self._route_open_channel)
|
||||
self.router.openPlaylist.connect(self._route_open_playlist)
|
||||
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:
|
||||
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:
|
||||
"""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.")
|
||||
return
|
||||
if not app_update.should_check():
|
||||
logger.debug("App version checked recently; skipping.")
|
||||
return
|
||||
|
||||
self.update_thread = UpdateCheckThread(self.version)
|
||||
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.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.addWidget(download_btn)
|
||||
button_layout.addWidget(remind_btn)
|
||||
button_layout.addWidget(skip_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Style the dialog with improved theme matching
|
||||
@@ -1191,6 +1224,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
|
||||
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):
|
||||
webbrowser.open(url)
|
||||
|
||||
@@ -1257,6 +1296,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
except Exception as 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
|
||||
if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
|
||||
logger.info("Stopping analysis thread...")
|
||||
@@ -1266,6 +1312,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self._analysis_thread.terminate()
|
||||
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
|
||||
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
|
||||
logger.info("Stopping auto-update thread...")
|
||||
@@ -1284,7 +1341,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self.current_download.terminate()
|
||||
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:
|
||||
ConfigManager.set("window_geometry", self.saveGeometry().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"),
|
||||
)
|
||||
|
||||
# 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
|
||||
dialog = AboutDialog(self)
|
||||
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.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:
|
||||
if self.current_download:
|
||||
self.current_download.cancelled = True
|
||||
@@ -1931,25 +2007,28 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
def run_dialog_with_blur(self, dialog: QDialog) -> int:
|
||||
"""Run a dialog with a static background screenshot blur to avoid QPainter conflicts."""
|
||||
|
||||
# 1. Capture the current state of the window (screenshot)
|
||||
pixmap = self.grab()
|
||||
|
||||
# 2. Create the blur using a Graphics Scene method (much safer than QGraphicsBlurEffect on live widget)
|
||||
# However, for simplicity and performance with PySide6, we can just apply a blur to the image
|
||||
# or use a simplified overlay.
|
||||
# Let's manually blur the pixmap or use a simpler transparent overlay if blur is too heavy manually.
|
||||
# Actually, using QGraphicsBlurEffect on a temporary QGraphicsScene rendering to a pixmap is a valid way
|
||||
# to generate a single blurred frame.
|
||||
|
||||
blurred_pixmap = self._apply_blur_to_pixmap(pixmap, radius=10)
|
||||
|
||||
# 3. Create an overlay widget that covers the Main Window
|
||||
overlay = QLabel(self)
|
||||
overlay.setPixmap(blurred_pixmap)
|
||||
# The blur is a screenshot of the window. grab() forces a framebuffer
|
||||
# readback on any QOpenGLWidget in the tree -- which returns black,
|
||||
# and risks the GL context the embedded mpv player renders into. Every
|
||||
# dialog in the app goes through here, so when a GL surface is present
|
||||
# dim instead of blurring: same effect, no screenshot, and faster.
|
||||
if self.findChild(QOpenGLWidget) is not None:
|
||||
overlay = QWidget(self)
|
||||
overlay.setAutoFillBackground(True)
|
||||
overlay.setStyleSheet("background-color: rgba(0, 0, 0, 150);")
|
||||
else:
|
||||
blurred_pixmap = self._apply_blur_to_pixmap(self.grab(), radius=10)
|
||||
overlay = QLabel(self)
|
||||
overlay.setPixmap(blurred_pixmap)
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# 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
|
||||
opacity_effect = QGraphicsOpacityEffect(overlay)
|
||||
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.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
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.QtGui import QOpenGLContext
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QStyle,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import ytsage_icons as icons
|
||||
from . import ytsage_theme as theme
|
||||
from .ytsage_player_input import SEEK_MEDIUM, build_context_menu, install_player_shortcuts
|
||||
from ..core.ytsage_mpv import probe_player
|
||||
from ..core.ytsage_yt_dlp import get_yt_dlp_path
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
@@ -59,27 +65,96 @@ def _ytdl_format_for(height: Optional[int]) -> 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 = []
|
||||
if ConfigManager.get("cookie_active"):
|
||||
if ConfigManager.get("cookie_source") == "file":
|
||||
path = ConfigManager.get("cookie_file_path")
|
||||
if path:
|
||||
opts.append(f"cookies={path}")
|
||||
opts.append(f"cookies={esc(path)}")
|
||||
else:
|
||||
browser = ConfigManager.get("cookie_browser")
|
||||
profile = ConfigManager.get("cookie_browser_profile")
|
||||
if browser:
|
||||
value = f"{browser}:{profile}" if profile else browser
|
||||
opts.append(f"cookies-from-browser={value}")
|
||||
opts.append(f"cookies-from-browser={esc(value)}")
|
||||
proxy = ConfigManager.get("proxy_url")
|
||||
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)
|
||||
|
||||
|
||||
def _default_hwdec() -> str:
|
||||
"""
|
||||
A platform-appropriate hwdec list.
|
||||
|
||||
Left unset, mpv's decoder choice varies with how libmpv was built, which
|
||||
makes playback problems unreproducible between machines. Naming the
|
||||
candidates makes it deterministic, and every list ends in `no` so a
|
||||
machine whose GPU interop is broken falls back to software decoding
|
||||
rather than showing a black frame.
|
||||
|
||||
Note this does *not* silence `Cannot load libcuda.so.1`: that comes from
|
||||
the GL/driver stack below mpv and appears with `hwdec=no` too. It is
|
||||
harmless stderr noise, not a decoder mpv chose.
|
||||
"""
|
||||
if sys.platform.startswith("win"):
|
||||
return "d3d11va,dxva2-copy,no"
|
||||
if sys.platform == "darwin":
|
||||
return "videotoolbox,no"
|
||||
return "vaapi,vaapi-copy,no"
|
||||
|
||||
|
||||
class MpvRenderWidget(QOpenGLWidget):
|
||||
"""QOpenGLWidget hosting a libmpv render context."""
|
||||
"""
|
||||
QOpenGLWidget hosting a libmpv render context.
|
||||
|
||||
Lifetime rule, and the reason this class is shaped the way it is
|
||||
-------------------------------------------------------------------
|
||||
The **mpv handle** is independent of OpenGL and owns playback state, so it
|
||||
lives as long as the widget. The **render context** belongs to exactly one
|
||||
QOpenGLContext and must not outlive it.
|
||||
|
||||
Qt destroys and recreates a widget's QOpenGLContext whenever the widget
|
||||
moves to another top-level window, and calls initializeGL() again on the
|
||||
new one. libmpv permits only one render context per handle, so the second
|
||||
creation fails -- and the previous code responded by assigning
|
||||
`self._render_ctx = None`, dropping the last Python reference to a context
|
||||
libmpv was still holding a function pointer into. python-mpv's
|
||||
MpvRenderContext has no __del__ and free() does not unregister the
|
||||
callback, so the ctypes trampoline was collected under libmpv's feet and
|
||||
the next frame notification jumped into freed memory. That was the
|
||||
segfault.
|
||||
|
||||
Two invariants prevent it:
|
||||
|
||||
1. initializeGL() tears down any existing render context first, so a
|
||||
second call is a clean recreation rather than an error.
|
||||
2. Teardown clears update_cb, calls free() with the GL context current,
|
||||
and only then drops the reference -- and it is wired to
|
||||
QOpenGLContext.aboutToBeDestroyed, so it runs *before* the GL context
|
||||
goes away rather than never.
|
||||
|
||||
Threading: libmpv fires property observers and the render-update callback
|
||||
on its own threads. Nothing in those callbacks touches Qt widgets; they
|
||||
only emit queued signals.
|
||||
"""
|
||||
|
||||
# Emitted from mpv threads; connected queued to GUI-thread slots
|
||||
mpvPositionChanged = Signal(float)
|
||||
@@ -87,22 +162,58 @@ class MpvRenderWidget(QOpenGLWidget):
|
||||
mpvPausedChanged = Signal(bool)
|
||||
mpvEndReached = Signal(str) # end-file reason
|
||||
mpvError = Signal(str)
|
||||
mpvMuteChanged = Signal(bool)
|
||||
mpvVolumeChanged = Signal(float)
|
||||
mpvBufferingChanged = Signal(bool)
|
||||
_renderUpdateRequested = Signal()
|
||||
|
||||
# Mouse gestures, surfaced for PlayerPanel to act on. Kept here rather
|
||||
# than on the panel so the control bar below the video is unaffected.
|
||||
clicked = Signal()
|
||||
doubleClicked = Signal()
|
||||
wheelScrolled = Signal(int, bool) # notches, ctrl_held
|
||||
mouseMovedOverVideo = Signal()
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setMinimumHeight(240)
|
||||
# Without this the widget cannot hold focus, which is why the panel's
|
||||
# key handler never ran.
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setMouseTracking(True)
|
||||
self._wheel_remainder = 0
|
||||
# A single click must not act until a double-click can be ruled out,
|
||||
# or double-click-to-fullscreen also pauses on the way.
|
||||
self._click_timer = QTimer(self)
|
||||
self._click_timer.setSingleShot(True)
|
||||
self._click_timer.timeout.connect(self.clicked)
|
||||
self._mpv = None
|
||||
self._render_ctx = None
|
||||
self._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()
|
||||
|
||||
# closeEvent is not the only way this process ends. Without this, an
|
||||
# exception during teardown or any exit that bypasses the main
|
||||
# window's closeEvent leaves libmpv running against a dying
|
||||
# interpreter.
|
||||
app = QCoreApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(self.shutdown)
|
||||
|
||||
# ------------------------------------------------------------------ mpv
|
||||
|
||||
def _create_mpv(self) -> None:
|
||||
import mpv
|
||||
|
||||
hwdec = ConfigManager.get("player.hwdec") or "auto"
|
||||
kwargs: Dict[str, Any] = {
|
||||
"vo": "libmpv",
|
||||
"ytdl": True,
|
||||
@@ -110,6 +221,11 @@ class MpvRenderWidget(QOpenGLWidget):
|
||||
"idle": "yes",
|
||||
"osc": False,
|
||||
"input_default_bindings": False,
|
||||
"hwdec": _default_hwdec() if hwdec == "auto" else str(hwdec),
|
||||
# Applied here rather than only through the slider: the slider
|
||||
# sets its restored value before its valueChanged is connected,
|
||||
# so mpv never saw it and playback always started at 100.
|
||||
"volume": max(0, min(100, int(ConfigManager.get("player.volume") or 100))),
|
||||
}
|
||||
ytdlp_path = get_yt_dlp_path()
|
||||
if str(ytdlp_path) != "yt-dlp":
|
||||
@@ -124,6 +240,13 @@ class MpvRenderWidget(QOpenGLWidget):
|
||||
self._mpv.observe_property("time-pos", self._on_time_pos)
|
||||
self._mpv.observe_property("duration", self._on_duration)
|
||||
self._mpv.observe_property("pause", self._on_pause)
|
||||
# Observed rather than assumed, so the button follows changes made
|
||||
# by a key, the menu or mpv itself.
|
||||
self._mpv.observe_property("mute", self._on_mute)
|
||||
self._mpv.observe_property("volume", self._on_volume)
|
||||
# Buffering was entirely invisible: a stall showed a frozen frame for
|
||||
# 25 seconds before the retry, with nothing on screen.
|
||||
self._mpv.observe_property("paused-for-cache", self._on_cache_pause)
|
||||
|
||||
@self._mpv.event_callback("end-file")
|
||||
def _on_end_file(event): # mpv thread
|
||||
@@ -146,19 +269,79 @@ class MpvRenderWidget(QOpenGLWidget):
|
||||
if value is not None:
|
||||
self.mpvPausedChanged.emit(bool(value))
|
||||
|
||||
def _on_mute(self, _name, value) -> None:
|
||||
if value is not None:
|
||||
self.mpvMuteChanged.emit(bool(value))
|
||||
|
||||
def _on_volume(self, _name, value) -> None:
|
||||
if value is not None:
|
||||
self.mpvVolumeChanged.emit(float(value))
|
||||
|
||||
def _on_cache_pause(self, _name, value) -> None:
|
||||
if value is not None:
|
||||
self.mpvBufferingChanged.emit(bool(value))
|
||||
|
||||
#: libmpv reports these at error level, but they are noise from the GL
|
||||
#: driver rather than a failure: playback continues and frames keep
|
||||
#: arriving. They were filling the error log with hundreds of lines per
|
||||
#: session, which buries the errors that do matter.
|
||||
_BENIGN_MPV_ERRORS = (
|
||||
"after creating texture: OpenGL error INVALID_ENUM",
|
||||
)
|
||||
|
||||
def _on_mpv_log(self, level: str, prefix: str, text: str) -> None:
|
||||
message = text.strip()
|
||||
if level in ("error", "fatal"):
|
||||
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":
|
||||
self.mpvError.emit(text.strip())
|
||||
self.mpvError.emit(message)
|
||||
else:
|
||||
logger.debug(f"mpv [{prefix}] {text.strip()}")
|
||||
logger.debug(f"mpv [{prefix}] {message}")
|
||||
|
||||
# --------------------------------------------------------------- OpenGL
|
||||
|
||||
def _on_render_update(self) -> None:
|
||||
"""
|
||||
libmpv render thread. Must not touch Qt widgets.
|
||||
|
||||
The alive gate matters: between clearing it and free() returning,
|
||||
libmpv may still invoke this, and by then the widget may be on its way
|
||||
out.
|
||||
"""
|
||||
if self._render_alive.is_set():
|
||||
self._renderUpdateRequested.emit()
|
||||
|
||||
@Slot()
|
||||
def _request_repaint(self) -> None:
|
||||
if self._render_ctx is not None and not self._shutting_down:
|
||||
self.update()
|
||||
|
||||
@Slot()
|
||||
def _on_frame_swapped(self) -> None:
|
||||
"""Let libmpv time its display-sync against real buffer swaps."""
|
||||
ctx = self._render_ctx
|
||||
if ctx is None or self._shutting_down:
|
||||
return
|
||||
try:
|
||||
ctx.report_swap()
|
||||
except Exception as e:
|
||||
logger.debug(f"mpv report_swap failed: {e}")
|
||||
|
||||
def initializeGL(self) -> None:
|
||||
from mpv import MpvGlGetProcAddressFn, MpvRenderContext
|
||||
|
||||
# A previous QOpenGLContext may still own a render context: Qt calls
|
||||
# initializeGL again after a context loss, and libmpv allows exactly
|
||||
# one per handle. Tearing down first is what turns "There is already a
|
||||
# mpv_render_context set" into an ordinary recreation.
|
||||
self._teardown_render_context()
|
||||
|
||||
if self._mpv is None or self._shutting_down:
|
||||
return
|
||||
|
||||
def get_proc_address(_ctx, name):
|
||||
glctx = QOpenGLContext.currentContext()
|
||||
if glctx is None:
|
||||
@@ -166,45 +349,170 @@ class MpvRenderWidget(QOpenGLWidget):
|
||||
address = glctx.getProcAddress(name if isinstance(name, bytes) else name.encode("utf-8"))
|
||||
return int(address) if address else 0
|
||||
|
||||
self._get_proc_address = MpvGlGetProcAddressFn(get_proc_address)
|
||||
# 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:
|
||||
self._render_ctx = MpvRenderContext(
|
||||
render_ctx = MpvRenderContext(
|
||||
self._mpv,
|
||||
"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:
|
||||
# Leave the widget black but keep the app alive (e.g. software GL
|
||||
# contexts that libmpv rejects)
|
||||
logger.error(f"Failed to create mpv render context: {e}")
|
||||
self._render_ctx = None
|
||||
self.mpvError.emit(f"Video output initialization failed: {e}")
|
||||
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:
|
||||
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
|
||||
ratio = self.devicePixelRatioF()
|
||||
w = int(self.width() * ratio)
|
||||
h = int(self.height() * ratio)
|
||||
self._render_ctx.render(
|
||||
flip_y=True,
|
||||
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
|
||||
)
|
||||
if w <= 0 or h <= 0:
|
||||
return
|
||||
try:
|
||||
render_ctx.render(
|
||||
flip_y=True,
|
||||
opengl_fbo={"fbo": self.defaultFramebufferObject(), "w": w, "h": h},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"mpv render failed: {e}")
|
||||
|
||||
def resizeGL(self, w: int, h: int) -> None:
|
||||
# The FBO size travels with every render call, so nothing needs
|
||||
# resizing here -- but a resize while paused must still repaint, or
|
||||
# the last frame stays stretched to the old geometry.
|
||||
self.update()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Idempotent: reachable from closeEvent, aboutToQuit and WatchPage."""
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._shutting_down = True
|
||||
|
||||
# Render context before the handle: terminating mpv while a render
|
||||
# context is registered is undefined.
|
||||
try:
|
||||
if self._render_ctx is not None:
|
||||
self._render_ctx.free()
|
||||
self._render_ctx = None
|
||||
if self.context() is not None:
|
||||
self.makeCurrent()
|
||||
try:
|
||||
self._teardown_render_context()
|
||||
finally:
|
||||
self.doneCurrent()
|
||||
else:
|
||||
self._teardown_render_context()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error freeing mpv render context: {e}")
|
||||
try:
|
||||
if self._mpv is not None:
|
||||
self._mpv.terminate()
|
||||
self._mpv = None
|
||||
except Exception as e:
|
||||
logger.debug(f"Error terminating mpv: {e}")
|
||||
logger.debug(f"Error tearing down mpv render context: {e}")
|
||||
|
||||
mpv_inst, self._mpv = self._mpv, None
|
||||
if mpv_inst is not None:
|
||||
try:
|
||||
mpv_inst.terminate()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error terminating mpv: {e}")
|
||||
|
||||
# ----------------------------------------------------------------- mouse
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
self._click_timer.start(QApplication.doubleClickInterval())
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._click_timer.stop() # cancel the pending single-click
|
||||
self.doubleClicked.emit()
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def wheelEvent(self, event) -> None:
|
||||
# Accumulate: a high-resolution trackpad sends far less than one
|
||||
# notch (120 units) per event, and dropping those makes it inert.
|
||||
self._wheel_remainder += event.angleDelta().y()
|
||||
notches, self._wheel_remainder = divmod(abs(self._wheel_remainder), 120)
|
||||
if notches:
|
||||
sign = 1 if event.angleDelta().y() > 0 else -1
|
||||
ctrl = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)
|
||||
self.wheelScrolled.emit(sign * int(notches), ctrl)
|
||||
event.accept()
|
||||
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
self.mouseMovedOverVideo.emit()
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
# ------------------------------------------------------------- controls
|
||||
|
||||
@@ -221,13 +529,17 @@ class PlayerPanel(QWidget):
|
||||
playbackEnded = Signal(str) # end-file reason ("eof", "error", ...)
|
||||
playerError = Signal(str)
|
||||
nowPlayingChanged = Signal(dict) # entry dict of the current item
|
||||
# The panel stays queue-agnostic: it asks, WatchPage decides what is next.
|
||||
nextRequested = Signal()
|
||||
previousRequested = Signal()
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._current_entry: Dict[str, Any] = {}
|
||||
self._duration: float = 0.0
|
||||
self._slider_down = False
|
||||
self._fullscreen_holder: Optional[QWidget] = None
|
||||
self._fullscreen_controller = None
|
||||
self._muted = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -236,26 +548,55 @@ class PlayerPanel(QWidget):
|
||||
self.video = MpvRenderWidget(self)
|
||||
layout.addWidget(self.video, stretch=1)
|
||||
|
||||
# A child of the video widget, not a sibling in the layout: it must
|
||||
# float over the frame without changing the geometry underneath.
|
||||
self.buffering_label = QLabel(_("player.buffering"), self.video)
|
||||
self.buffering_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.buffering_label.setStyleSheet(
|
||||
f"background-color: rgba(0,0,0,170); color: {theme.TEXT};"
|
||||
"padding: 8px 16px; border-radius: 6px; font-size: 13px;"
|
||||
)
|
||||
self.buffering_label.hide()
|
||||
|
||||
self.title_label = QLabel("")
|
||||
self.title_label.setStyleSheet("font-weight: bold; padding: 2px 6px;")
|
||||
self.title_label.setWordWrap(True)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
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.setContentsMargins(6, 0, 6, 4)
|
||||
|
||||
self.prev_btn = QPushButton()
|
||||
self.prev_btn.setIcon(icons.icon("skip-back", theme.ICON_ON_ACCENT))
|
||||
self.prev_btn.setFixedWidth(36)
|
||||
self.prev_btn.setToolTip(_("player.previous"))
|
||||
self.prev_btn.clicked.connect(self.request_previous)
|
||||
controls.addWidget(self.prev_btn)
|
||||
|
||||
self.play_btn = QPushButton()
|
||||
self.play_btn.setIcon(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.setToolTip(_("player.play_pause"))
|
||||
self.play_btn.clicked.connect(self.toggle_pause)
|
||||
controls.addWidget(self.play_btn)
|
||||
|
||||
self.next_btn = QPushButton()
|
||||
self.next_btn.setIcon(icons.icon("skip-forward", theme.ICON_ON_ACCENT))
|
||||
self.next_btn.setFixedWidth(36)
|
||||
self.next_btn.setToolTip(_("player.next"))
|
||||
self.next_btn.clicked.connect(self.request_next)
|
||||
controls.addWidget(self.next_btn)
|
||||
|
||||
self.time_label = QLabel("0:00 / 0:00")
|
||||
controls.addWidget(self.time_label)
|
||||
|
||||
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.seek_slider.setRange(0, 1000)
|
||||
self.seek_slider.setToolTip(_("player.seek_tooltip"))
|
||||
self.seek_slider.sliderPressed.connect(self._on_slider_pressed)
|
||||
self.seek_slider.sliderReleased.connect(self._on_slider_released)
|
||||
controls.addWidget(self.seek_slider, stretch=1)
|
||||
@@ -266,6 +607,7 @@ class PlayerPanel(QWidget):
|
||||
default_q = ConfigManager.get("player.default_quality")
|
||||
idx = next((i for i, (_l, h) in enumerate(QUALITY_CHOICES) if h == default_q), 0)
|
||||
self.quality_combo.setCurrentIndex(idx)
|
||||
self.quality_combo.setToolTip(_("player.quality_tooltip"))
|
||||
self.quality_combo.currentIndexChanged.connect(self._on_quality_changed)
|
||||
controls.addWidget(self.quality_combo)
|
||||
|
||||
@@ -273,6 +615,7 @@ class PlayerPanel(QWidget):
|
||||
for s in SPEED_CHOICES:
|
||||
self.speed_combo.addItem(f"{s:g}x", s)
|
||||
self.speed_combo.setCurrentIndex(SPEED_CHOICES.index(1.0))
|
||||
self.speed_combo.setToolTip(_("player.speed_tooltip"))
|
||||
self.speed_combo.currentIndexChanged.connect(self._on_speed_changed)
|
||||
controls.addWidget(self.speed_combo)
|
||||
|
||||
@@ -280,21 +623,31 @@ class PlayerPanel(QWidget):
|
||||
self.volume_slider.setRange(0, 100)
|
||||
self.volume_slider.setFixedWidth(90)
|
||||
self.volume_slider.setValue(int(ConfigManager.get("player.volume") or 100))
|
||||
self.volume_slider.setToolTip(_("player.volume_tooltip"))
|
||||
self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
||||
|
||||
self.mute_btn = QPushButton()
|
||||
self.mute_btn.setIcon(icons.icon("volume-high", theme.ICON_ON_ACCENT))
|
||||
self.mute_btn.setFixedWidth(36)
|
||||
self.mute_btn.setToolTip(_("player.act_mute"))
|
||||
self.mute_btn.clicked.connect(self.toggle_mute)
|
||||
controls.addWidget(self.mute_btn)
|
||||
controls.addWidget(self.volume_slider)
|
||||
|
||||
self.subs_btn = QPushButton(_("player.subtitles"))
|
||||
self.subs_btn.setCheckable(True)
|
||||
self.subs_btn.setToolTip(_("player.subtitles_tooltip"))
|
||||
self.subs_btn.toggled.connect(self._on_subs_toggled)
|
||||
controls.addWidget(self.subs_btn)
|
||||
|
||||
self.fullscreen_btn = QPushButton()
|
||||
self.fullscreen_btn.setIcon(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.setToolTip(_("player.fullscreen"))
|
||||
self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
|
||||
controls.addWidget(self.fullscreen_btn)
|
||||
|
||||
layout.addLayout(controls)
|
||||
layout.addWidget(self.controls_bar)
|
||||
|
||||
# mpv-thread signals arrive queued on the GUI thread
|
||||
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.mpvEndReached.connect(self._on_end_reached, Qt.ConnectionType.QueuedConnection)
|
||||
self.video.mpvError.connect(self.playerError, Qt.ConnectionType.QueuedConnection)
|
||||
self.video.mpvMuteChanged.connect(self._on_mute_changed, Qt.ConnectionType.QueuedConnection)
|
||||
self.video.mpvVolumeChanged.connect(self._on_mpv_volume, Qt.ConnectionType.QueuedConnection)
|
||||
self.video.mpvBufferingChanged.connect(self._on_buffering, Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
self._volume_apply_timer = QTimer(self)
|
||||
self._volume_apply_timer.setSingleShot(True)
|
||||
@@ -318,6 +674,36 @@ class PlayerPanel(QWidget):
|
||||
self._stall_retries = 0
|
||||
self._playback_started = False
|
||||
|
||||
# --- input ------------------------------------------------------
|
||||
# The panel must be able to hold focus for its shortcuts to fire...
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
# ...and the control bar must not take it, or clicking play would
|
||||
# park focus on a button that then swallows Space. This is why the
|
||||
# old keyPressEvent never ran.
|
||||
for widget in (
|
||||
self.prev_btn, self.play_btn, self.next_btn, self.mute_btn,
|
||||
self.subs_btn, self.fullscreen_btn, self.seek_slider,
|
||||
self.volume_slider, self.quality_combo, self.speed_combo,
|
||||
):
|
||||
widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
|
||||
# Kept on the instance: an unreferenced QShortcut is collected and
|
||||
# silently stops working.
|
||||
self._shortcuts = install_player_shortcuts(self)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.DefaultContextMenu)
|
||||
|
||||
self.video.clicked.connect(self.toggle_pause)
|
||||
self.video.doubleClicked.connect(self.toggle_fullscreen)
|
||||
self.video.wheelScrolled.connect(self._on_wheel)
|
||||
self.video.mouseMovedOverVideo.connect(self._on_mouse_activity)
|
||||
|
||||
# Controls hide themselves in fullscreen only; doing it in a window
|
||||
# would make the layout jump under the pointer.
|
||||
self._idle_timer = QTimer(self)
|
||||
self._idle_timer.setSingleShot(True)
|
||||
self._idle_timer.setInterval(2500)
|
||||
self._idle_timer.timeout.connect(self._hide_idle_controls)
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def play(self, entry: Dict[str, Any], resume_pos: float = 0.0) -> None:
|
||||
@@ -376,6 +762,132 @@ class PlayerPanel(QWidget):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------- bound actions
|
||||
# Every method below is reachable from a key, the context menu, or both;
|
||||
# see ytsage_player_input.ACTIONS. They are all no-ops when nothing is
|
||||
# loaded rather than raising, because a key can be pressed at any time.
|
||||
|
||||
def seek_relative(self, seconds: float) -> None:
|
||||
mpv_inst = self.video.mpv
|
||||
if mpv_inst is None:
|
||||
return
|
||||
try:
|
||||
mpv_inst.seek(seconds, reference="relative")
|
||||
except Exception as e:
|
||||
logger.debug(f"Relative seek failed: {e}")
|
||||
|
||||
def seek_absolute(self, seconds: float) -> None:
|
||||
mpv_inst = self.video.mpv
|
||||
if mpv_inst is None:
|
||||
return
|
||||
try:
|
||||
mpv_inst.seek(max(0.0, seconds), reference="absolute")
|
||||
except Exception as e:
|
||||
logger.debug(f"Absolute seek failed: {e}")
|
||||
|
||||
def seek_percent(self, fraction: float) -> None:
|
||||
if self._duration > 0:
|
||||
self.seek_absolute(self._duration * max(0.0, min(1.0, fraction)))
|
||||
|
||||
def seek_to_end(self) -> None:
|
||||
if self._duration > 0:
|
||||
# Not exactly the end: mpv would treat that as EOF and advance.
|
||||
self.seek_absolute(max(0.0, self._duration - 3.0))
|
||||
|
||||
def frame_step(self, direction: int) -> None:
|
||||
mpv_inst = self.video.mpv
|
||||
if mpv_inst is None:
|
||||
return
|
||||
try:
|
||||
mpv_inst.command("frame-back-step" if direction < 0 else "frame-step")
|
||||
except Exception as e:
|
||||
logger.debug(f"Frame step failed: {e}")
|
||||
|
||||
def nudge_volume(self, delta: int) -> None:
|
||||
self.volume_slider.setValue(max(0, min(100, self.volume_slider.value() + delta)))
|
||||
|
||||
def toggle_mute(self) -> None:
|
||||
mpv_inst = self.video.mpv
|
||||
if mpv_inst is None:
|
||||
return
|
||||
try:
|
||||
mpv_inst["mute"] = not bool(mpv_inst["mute"])
|
||||
except Exception as e:
|
||||
logger.debug(f"Mute toggle failed: {e}")
|
||||
|
||||
def nudge_speed(self, delta: float) -> None:
|
||||
mpv_inst = self.video.mpv
|
||||
if mpv_inst is None:
|
||||
return
|
||||
try:
|
||||
current = float(mpv_inst["speed"] or 1.0)
|
||||
except Exception:
|
||||
current = 1.0
|
||||
self._apply_speed(max(0.25, min(4.0, round((current + delta) * 100) / 100)))
|
||||
|
||||
def reset_speed(self) -> None:
|
||||
self._apply_speed(1.0)
|
||||
|
||||
def _apply_speed(self, speed: float) -> None:
|
||||
mpv_inst = self.video.mpv
|
||||
if mpv_inst is not None:
|
||||
try:
|
||||
mpv_inst["speed"] = speed
|
||||
except Exception as e:
|
||||
logger.debug(f"Speed change failed: {e}")
|
||||
# Reflect it in the combo when it is one of the offered values,
|
||||
# without re-triggering the handler.
|
||||
index = self.speed_combo.findData(speed)
|
||||
if index >= 0 and index != self.speed_combo.currentIndex():
|
||||
self.speed_combo.blockSignals(True)
|
||||
self.speed_combo.setCurrentIndex(index)
|
||||
self.speed_combo.blockSignals(False)
|
||||
|
||||
def cycle_subtitles(self) -> None:
|
||||
self.subs_btn.setChecked(not self.subs_btn.isChecked())
|
||||
|
||||
def request_next(self) -> None:
|
||||
self.nextRequested.emit()
|
||||
|
||||
def request_previous(self) -> None:
|
||||
self.previousRequested.emit()
|
||||
|
||||
def copy_video_url(self) -> None:
|
||||
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
|
||||
if url:
|
||||
QApplication.clipboard().setText(str(url))
|
||||
|
||||
def open_in_browser(self) -> None:
|
||||
url = self._current_entry.get("url") or self._current_entry.get("webpage_url")
|
||||
if url:
|
||||
webbrowser.open(str(url))
|
||||
|
||||
def reload_auth(self) -> None:
|
||||
"""
|
||||
Push changed cookie/proxy settings onto the live mpv handle.
|
||||
|
||||
These were read once, when the widget was built, so signing in only
|
||||
reached playback after restarting the app. ytdl_hook consults the
|
||||
option when it resolves a URL, so anything already playing keeps the
|
||||
streams it resolved with -- it is reloaded at its current position.
|
||||
"""
|
||||
mpv_inst = self.video.mpv
|
||||
if mpv_inst is None:
|
||||
return
|
||||
raw_opts = _build_ytdl_raw_options()
|
||||
try:
|
||||
mpv_inst["ytdl-raw-options"] = raw_opts
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not update ytdl-raw-options: {e}")
|
||||
return
|
||||
logger.info("Player authentication settings reloaded.")
|
||||
|
||||
if self._current_entry and self._playback_started:
|
||||
position = self.current_position()
|
||||
entry = self._current_entry
|
||||
self._current_entry = {}
|
||||
self.play(entry, resume_pos=position)
|
||||
|
||||
def current_entry(self) -> Dict[str, Any]:
|
||||
return dict(self._current_entry)
|
||||
|
||||
@@ -411,8 +923,40 @@ class PlayerPanel(QWidget):
|
||||
|
||||
@Slot(bool)
|
||||
def _on_paused_changed(self, paused: bool) -> None:
|
||||
icon = QStyle.StandardPixmap.SP_MediaPlay if paused else QStyle.StandardPixmap.SP_MediaPause
|
||||
self.play_btn.setIcon(self.style().standardIcon(icon))
|
||||
self.play_btn.setIcon(icons.icon("play" if paused else "pause", theme.ICON_ON_ACCENT))
|
||||
self.play_btn.setToolTip(_("player.play") if paused else _("player.pause"))
|
||||
|
||||
@Slot(bool)
|
||||
def _on_mute_changed(self, muted: bool) -> None:
|
||||
self._muted = muted
|
||||
self._refresh_volume_icon()
|
||||
|
||||
@Slot(float)
|
||||
def _on_mpv_volume(self, volume: float) -> None:
|
||||
# Reflect changes made by key or menu, without echoing back into mpv.
|
||||
value = int(round(volume))
|
||||
if value != self.volume_slider.value():
|
||||
self.volume_slider.blockSignals(True)
|
||||
self.volume_slider.setValue(value)
|
||||
self.volume_slider.blockSignals(False)
|
||||
self._volume_apply_timer.start()
|
||||
self._refresh_volume_icon()
|
||||
|
||||
def _refresh_volume_icon(self) -> None:
|
||||
if self._muted or self.volume_slider.value() == 0:
|
||||
name = "volume-mute"
|
||||
elif self.volume_slider.value() < 50:
|
||||
name = "volume-low"
|
||||
else:
|
||||
name = "volume-high"
|
||||
self.mute_btn.setIcon(icons.icon(name, theme.ICON_ON_ACCENT))
|
||||
self.mute_btn.setToolTip(_("player.act_unmute") if self._muted else _("player.act_mute"))
|
||||
|
||||
@Slot(bool)
|
||||
def _on_buffering(self, buffering: bool) -> None:
|
||||
self.buffering_label.setVisible(buffering)
|
||||
if buffering:
|
||||
self.buffering_label.raise_()
|
||||
|
||||
@Slot(str)
|
||||
def _on_end_reached(self, reason: str) -> None:
|
||||
@@ -439,8 +983,10 @@ class PlayerPanel(QWidget):
|
||||
if mpv_inst is None:
|
||||
return
|
||||
mpv_inst["ytdl-format"] = _ytdl_format_for(height)
|
||||
# Reload the current item at the new quality, keeping position
|
||||
if self._current_entry:
|
||||
# Reload the current item at the new quality, keeping position. Only
|
||||
# when something is actually loaded -- otherwise changing the default
|
||||
# quality would start playing whatever was last selected.
|
||||
if self._current_entry and self._playback_started:
|
||||
pos = self.current_position()
|
||||
self.play(self._current_entry, resume_pos=pos + 5.0 if pos else 0.0)
|
||||
|
||||
@@ -472,32 +1018,97 @@ class PlayerPanel(QWidget):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def toggle_fullscreen(self) -> None:
|
||||
if self._fullscreen_holder is None:
|
||||
self._fullscreen_parent_layout = self.parentWidget().layout() if self.parentWidget() else None
|
||||
self._fullscreen_holder = self.parentWidget()
|
||||
self.setParent(None)
|
||||
self.setWindowFlags(Qt.WindowType.Window)
|
||||
self.showFullScreen()
|
||||
else:
|
||||
self.setWindowFlags(Qt.WindowType.Widget)
|
||||
if self._fullscreen_parent_layout is not None:
|
||||
self._fullscreen_parent_layout.addWidget(self)
|
||||
else:
|
||||
self.setParent(self._fullscreen_holder)
|
||||
self.showNormal()
|
||||
self.show()
|
||||
self._fullscreen_holder = None
|
||||
# ---------------------------------------------------------- mouse/idle
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() == Qt.Key.Key_Escape and self._fullscreen_holder is not None:
|
||||
self.toggle_fullscreen()
|
||||
elif event.key() == Qt.Key.Key_Space:
|
||||
self.toggle_pause()
|
||||
elif event.key() == Qt.Key.Key_F:
|
||||
self.toggle_fullscreen()
|
||||
def _on_wheel(self, notches: int, ctrl_held: bool) -> None:
|
||||
if ctrl_held:
|
||||
self.seek_relative(notches * SEEK_MEDIUM)
|
||||
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):
|
||||
|
||||
@@ -17,3 +17,10 @@ class AppRouter(QObject):
|
||||
downloadVideo = Signal(str) # open Downloads tab with URL prefilled + analyzed
|
||||
openChannel = Signal(str) # open a channel 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)
|
||||
|
||||
self.search_btn = QPushButton(_("search.button"))
|
||||
self.search_btn.setToolTip(_("search.button_tooltip"))
|
||||
self.search_btn.clicked.connect(self.start_search)
|
||||
self.search_btn.setMinimumHeight(38)
|
||||
bar.addWidget(self.search_btn)
|
||||
|
||||
@@ -34,6 +34,9 @@ class WatchPage(QWidget):
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
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.setContentsMargins(8, 8, 8, 8)
|
||||
@@ -44,7 +47,9 @@ class WatchPage(QWidget):
|
||||
self.player = create_player_panel(self)
|
||||
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.setContentsMargins(4, 0, 0, 0)
|
||||
|
||||
@@ -79,6 +84,9 @@ class WatchPage(QWidget):
|
||||
self._position_timer.setInterval(POSITION_SAVE_INTERVAL_MS)
|
||||
self._position_timer.timeout.connect(self._save_position)
|
||||
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()
|
||||
|
||||
@@ -112,6 +120,39 @@ class WatchPage(QWidget):
|
||||
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())]
|
||||
|
||||
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
|
||||
|
||||
def _play_next_from_queue(self) -> None:
|
||||
@@ -146,6 +187,11 @@ class WatchPage(QWidget):
|
||||
|
||||
def _on_now_playing(self, entry: Dict[str, Any]) -> None:
|
||||
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._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.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QStackedWidget,
|
||||
QTabBar,
|
||||
@@ -19,6 +21,13 @@ class FadingStackedWidget(QStackedWidget):
|
||||
self.fade_duration = 300
|
||||
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):
|
||||
curr_index = self.currentIndex()
|
||||
if index == curr_index:
|
||||
@@ -26,12 +35,21 @@ class FadingStackedWidget(QStackedWidget):
|
||||
|
||||
widget = self.widget(index)
|
||||
curr_widget = self.widget(curr_index)
|
||||
|
||||
|
||||
# If widget isn't visible or valid, just swap
|
||||
if not self.isVisible() or not curr_widget:
|
||||
super().setCurrentIndex(index)
|
||||
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)
|
||||
# Use grab() for simplicity and reliability in PySide6
|
||||
pixmap = self.grab()
|
||||
@@ -77,20 +95,38 @@ class SmoothTabWidget(QWidget):
|
||||
A unified Widget that behaves like a QTabWidget but uses smooth fading transitions.
|
||||
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):
|
||||
super().__init__(parent)
|
||||
|
||||
|
||||
# Main Layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setSpacing(0)
|
||||
|
||||
# Tab Bar
|
||||
self.tab_bar = QTabBar(self)
|
||||
self.tab_bar.setDrawBase(False) # We draw border on content instead
|
||||
|
||||
# Tab bar, plus a right-aligned slot for a corner widget. Wrapped in a
|
||||
# row so something (the account button) can sit opposite the tabs the
|
||||
# way QTabWidget::setCornerWidget would allow.
|
||||
self.tab_row = QWidget(self)
|
||||
tab_row_layout = QHBoxLayout(self.tab_row)
|
||||
tab_row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
tab_row_layout.setSpacing(0)
|
||||
|
||||
self.tab_bar = QTabBar(self.tab_row)
|
||||
self.tab_bar.setDrawBase(False) # We draw border on content instead
|
||||
self.tab_bar.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
|
||||
self.content_frame = QFrame(self)
|
||||
self.content_frame.setObjectName("tabContent")
|
||||
@@ -106,18 +142,70 @@ class SmoothTabWidget(QWidget):
|
||||
|
||||
self.layout.addWidget(self.content_frame)
|
||||
|
||||
def addTab(self, widget, label):
|
||||
"""Add a tab with the given widget and label."""
|
||||
def addTab(self, widget, label, icon=None):
|
||||
"""Add a tab with the given widget, label and optional icon."""
|
||||
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):
|
||||
"""Slot to handle tab bar clicks."""
|
||||
self.tab_bar.setCurrentIndex(index)
|
||||
self.stack.setCurrentIndex(index)
|
||||
if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index:
|
||||
return
|
||||
# setCurrentIndex on the bar emits its own currentChanged, which is
|
||||
# connected straight back here -- and at that moment the stack has not
|
||||
# moved yet, so a plain index comparison does not catch the re-entry.
|
||||
# Without this flag every switch emitted currentChanged twice and each
|
||||
# page's activation hook ran twice.
|
||||
if self._switching:
|
||||
return
|
||||
self._switching = True
|
||||
try:
|
||||
self.tab_bar.setCurrentIndex(index)
|
||||
self.stack.setCurrentIndex(index)
|
||||
finally:
|
||||
self._switching = False
|
||||
self.currentChanged.emit(index)
|
||||
|
||||
def currentWidget(self):
|
||||
return self.stack.currentWidget()
|
||||
|
||||
def currentIndex(self):
|
||||
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;
|
||||
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 {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #666666;
|
||||
background: #1d1e22;
|
||||
border-radius: 9px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox::indicator:unchecked:hover {
|
||||
border-color: #9aa0a6;
|
||||
}
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #c90000;
|
||||
background: #c90000;
|
||||
border-radius: 9px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QCheckBox:disabled { color: #888888; }
|
||||
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_switch_failed": "❌ Failed to switch channel: {error}",
|
||||
"ytdlp_current_channel": "Current channel: {channel}",
|
||||
"app_updates_title": "YTSage Updates",
|
||||
"check_app_updates": "Check for YTSage updates on startup",
|
||||
"app_updates_title": "SageTube Updates",
|
||||
"check_app_updates": "Check for SageTube updates on startup",
|
||||
"check_beta_updates": "Receive Beta Updates",
|
||||
"auto_update_title": "Auto-Update Settings",
|
||||
"auto_update_header": "🔄 Auto-Update Settings",
|
||||
@@ -466,12 +466,15 @@
|
||||
},
|
||||
"update_dialog": {
|
||||
"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:",
|
||||
"latest_version_label": "Latest version:",
|
||||
"changelog": "Changelog",
|
||||
"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": {
|
||||
"unknown": "Unknown Playlist",
|
||||
@@ -648,14 +651,53 @@
|
||||
"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."
|
||||
"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": {
|
||||
"play": "▶ Play",
|
||||
"queue": "+ Queue",
|
||||
"download": "⬇",
|
||||
"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": {
|
||||
"watch": "Watch",
|
||||
@@ -669,7 +711,8 @@
|
||||
"button": "Search",
|
||||
"searching": "Searching...",
|
||||
"results_count": "{count} results",
|
||||
"failed": "Search failed: {error}"
|
||||
"failed": "Search failed: {error}",
|
||||
"button_tooltip": "Search YouTube"
|
||||
},
|
||||
"watch": {
|
||||
"clear_queue": "Clear queue"
|
||||
@@ -687,7 +730,13 @@
|
||||
"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."
|
||||
"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",
|
||||
@@ -702,6 +751,23 @@
|
||||
"account_failed": "Account feed failed (are cookies valid?): {error}",
|
||||
"subscribed": "Subscribed to {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
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, Qt
|
||||
from PySide6.QtGui import QSurfaceFormat
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
from .utils.ytsage_logger import logger
|
||||
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):
|
||||
# A QMessageBox needs a live QApplication; if startup failed before (or
|
||||
# while) creating one, constructing the dialog would abort the process
|
||||
@@ -23,11 +51,18 @@ def show_error_dialog(message):
|
||||
|
||||
def main():
|
||||
try:
|
||||
logger.info("Starting YTSage application")
|
||||
logger.info("Starting SageTube application")
|
||||
_configure_opengl()
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("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.show()
|
||||
logger.info("Application window shown, entering main loop")
|
||||
|
||||
@@ -72,6 +72,9 @@ class ConfigManager:
|
||||
_config_file: Path = APP_CONFIG_FILE
|
||||
_settings: 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"),
|
||||
"generic_mode": True,
|
||||
"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_beta_updates": False,
|
||||
"last_update_check": 0,
|
||||
"skipped_update_version": None, # set by the update dialog's "Skip this version"
|
||||
"concurrent_fragments": 1,
|
||||
"language": "en",
|
||||
"ytdlp_channel": "stable",
|
||||
@@ -114,14 +118,74 @@ class ConfigManager:
|
||||
"volume": 100,
|
||||
"resume": "auto", # auto | off
|
||||
"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": {
|
||||
"mode": "local", # local (per-channel aggregation) | account (cookies)
|
||||
"per_channel_items": 15,
|
||||
"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
|
||||
def _load(cls) -> None:
|
||||
"""
|
||||
@@ -134,11 +198,21 @@ class ConfigManager:
|
||||
try:
|
||||
with open(cls._config_file, "r", encoding="utf-8") as 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
|
||||
# exist without call sites needing `or <default>` fallbacks
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
cls._settings.update(stored)
|
||||
cls._settings = cls._deep_merge(cls._default_config, stored)
|
||||
logger.info("Config loaded from file.")
|
||||
if migrated:
|
||||
cls._save()
|
||||
except json.JSONDecodeError:
|
||||
cls._settings = copy.deepcopy(cls._default_config)
|
||||
logger.warning("Config file corrupt, loaded defaults.")
|
||||
|
||||
@@ -78,6 +78,8 @@ def get_asset_path(asset_relative_path: str) -> Path:
|
||||
|
||||
# Assets Constants
|
||||
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")
|
||||
|
||||
OS_NAME: str = platform.system() # Windows ; Darwin ; Linux
|
||||
|
||||
@@ -165,14 +165,27 @@ class LibraryManager:
|
||||
conn.commit()
|
||||
|
||||
@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:
|
||||
rows = cls._conn().execute(
|
||||
"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()
|
||||
rows = cls._conn().execute(query, tuple(params)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ------------------------------------------------------- watch history
|
||||
|
||||
@@ -74,10 +74,149 @@ class LocalizationManager:
|
||||
"generic_mode": "Generic Mode",
|
||||
"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.",
|
||||
"app_updates_title": "YTSage Updates",
|
||||
"check_app_updates": "Check for YTSage updates on startup",
|
||||
"app_updates_title": "SageTube Updates",
|
||||
"check_app_updates": "Check for SageTube updates on startup",
|
||||
"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": {
|
||||
"cookies": "Login with Cookies",
|
||||
"custom_command": "Custom Command",
|
||||
|
||||
Reference in New Issue
Block a user