feat(app): the round's wiring — gamescope, watchdog, geometry, hearts
- Steam PR1/PR3/PR4 wiring: apply_gamescope_quirks at the top of main(), tray + miniplayer suppression, gilrs spawn, UiFocusState OSK hook. - #558 watchdog arm at window-ready; #654 monitor-DPR geometry clamp; local-heart routing in the favorite arms; play-later media-action arm; sort-flip handlers with direction (#657/Library All pattern); gilrs + wayland-data-control deps (#514). km
This commit is contained in:
4 files changed
+786
-53
No files matched your search
Generated
+144
-3
@@ -358,6 +358,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -2286,6 +2287,12 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -2669,6 +2676,40 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gilrs"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"gilrs-core",
|
||||
"log",
|
||||
"uuid 1.22.0",
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gilrs-core"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9"
|
||||
dependencies = [
|
||||
"inotify",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"libudev-sys",
|
||||
"log",
|
||||
"nix 0.31.3",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"uuid 1.22.0",
|
||||
"vec_map",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -3623,6 +3664,26 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -4011,9 +4072,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
@@ -4566,6 +4627,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
@@ -5032,6 +5105,17 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -5265,6 +5349,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.1"
|
||||
@@ -5432,6 +5526,17 @@ dependencies = [
|
||||
"pest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset 0.5.7",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
@@ -5871,6 +5976,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"gilrs",
|
||||
"i-slint-backend-winit",
|
||||
"i-slint-renderer-femtovg",
|
||||
"image",
|
||||
@@ -8405,7 +8511,7 @@ dependencies = [
|
||||
"fancy-regex",
|
||||
"filedescriptor",
|
||||
"finl_unicode",
|
||||
"fixedbitset",
|
||||
"fixedbitset 0.4.2",
|
||||
"hex",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
@@ -8869,6 +8975,17 @@ dependencies = [
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom 8.0.0",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -9158,6 +9275,12 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -10477,6 +10600,24 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.4",
|
||||
"thiserror 2.0.18",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write-fonts"
|
||||
version = "0.45.0"
|
||||
|
||||
@@ -1933,6 +1933,10 @@ export global SidebarState {
|
||||
// Active sort option: "name" | "recent" | "tracks" | "playcount"
|
||||
// | "custom" (mirrors Tauri's SortOption). Persisted by crate::sidebar.
|
||||
in-out property <string> sort-option: "name";
|
||||
// Direction of the active sort (re-selecting the option in the menu
|
||||
// flips it). true = the option's natural direction (name A→Z, recent
|
||||
// newest-first, tracks most-first, custom position asc).
|
||||
in-out property <bool> sort-asc: true;
|
||||
// Micro-collage of track covers on playlist rows. Opt-out (ON by
|
||||
// default) to match Tauri's `sidebarPlaylistCollage`. The Settings >
|
||||
// Appearance toggle wires into this in a later step.
|
||||
@@ -2315,6 +2319,10 @@ export global PlaylistManagerState {
|
||||
in-out property <string> filter: "all";
|
||||
// "name" | "recent" | "playcount" | "tracks" | "custom".
|
||||
in-out property <string> sort: "name";
|
||||
// Direction of the active sort (re-selecting the menu option flips it).
|
||||
// true = natural direction (name A→Z, recent newest-first, playcount /
|
||||
// tracks most-first, custom position asc).
|
||||
in-out property <bool> sort-asc: true;
|
||||
// "grid" | "list" | "tree".
|
||||
in-out property <string> view-mode: "grid";
|
||||
// Folder mode on (folders shown + folder filtering) vs flat.
|
||||
@@ -3236,6 +3244,12 @@ export global AppearanceState {
|
||||
in-out property <bool> intelligent-search: true;
|
||||
// Player volume +/- stepper buttons — opt-in, hidden by default.
|
||||
in-out property <bool> show-volume-steppers: false;
|
||||
// Animate the now-playing row's equalizer bars. Default OFF — a static
|
||||
// pause glyph + accent left-edge mark instead (CPU on some machines).
|
||||
in-out property <bool> play-indicator-animation: false;
|
||||
// Invert the two-finger touchpad swipe navigation (#653). Default OFF =
|
||||
// natural-scrolling convention (fingers right = back, left = forward).
|
||||
in-out property <bool> invert-swipe-navigation: false;
|
||||
|
||||
// --- NOTIFICATIONS -----------------------------------------------------
|
||||
in-out property <bool> in-app-toasts: true;
|
||||
@@ -3476,6 +3490,50 @@ export global NavState {
|
||||
in-out property <length> scroll-restore: 0;
|
||||
}
|
||||
|
||||
// Kiosk keyboard/gamepad navigation model (Steam Deck Game Mode plan, PR 2 —
|
||||
// feature/internal/kiosk-key-nav). Pure Slint: the root FocusScope in
|
||||
// KioskShell drives these properties from arrow/Enter/Esc keys; PR 3's
|
||||
// gamepad layer injects the same synthetic key events, so nothing here is
|
||||
// keyboard-specific. Additive with safe defaults — the desktop AppShell never
|
||||
// touches this global.
|
||||
//
|
||||
// Zone flow: "rail" (bottom NavRail tiles) → "content" (the mounted view's
|
||||
// navigable items) → "player" (the transport bar, one focusable unit).
|
||||
// Down advances rail→content→player→rail; Up reverses. `index` is the
|
||||
// focused item inside the zone. For content, the index space is
|
||||
// `tabs` leading tab-strip entries followed by the item rows laid out as a
|
||||
// `columns`-wide grid (`columns == 1` = vertical list or card shelves);
|
||||
// the mounted view publishes `tabs` / `columns` / `count` / `shelves`
|
||||
// whenever its tab or data changes. `nav-active` latches true on the first
|
||||
// nav key and gates every focus-ring rendering (touch-only sessions never
|
||||
// show a ring).
|
||||
export global KioskNavState {
|
||||
in-out property <string> zone: "content"; // "rail" | "content" | "player"
|
||||
in-out property <int> index: 0;
|
||||
in-out property <int> columns: 1;
|
||||
in-out property <int> count: 0; // tabs + item count of the mounted content view
|
||||
in-out property <bool> nav-active: false;
|
||||
// Leading tab-strip entries inside the content index space (0 = the view
|
||||
// has no tabs). Owned by the mounted view, never by shared grids.
|
||||
in-out property <int> tabs: 0;
|
||||
// Shelf mode (Discover / Artist): each content row is a horizontal card
|
||||
// strip with its own cursor, so Left/Right can't move `index` (that
|
||||
// would jump shelves). The shell emits an `hmove` pulse instead and the
|
||||
// focused shelf consumes it into its local cursor.
|
||||
in-out property <bool> shelves: false;
|
||||
in-out property <int> hmove: 0; // pending shelf move: -1 | +1 (consumer resets to 0)
|
||||
// Last vertical move direction (-1 up / +1 down / 0 none). Shelf views
|
||||
// skip non-navigable rows (e.g. playlist/mix sections that render no
|
||||
// album strip) by nudging `index` one more step this way.
|
||||
in-out property <int> dir: 0;
|
||||
// Enter pulse: the shell increments this instead of calling into views
|
||||
// (conditionally-mounted children can't be addressed). The mounted zone
|
||||
// owner (view, grid or list) reacts via a `changed` probe, checks the
|
||||
// zone, and activates `index`. NavRail is always mounted, so the shell
|
||||
// calls its `activate-index()` directly instead.
|
||||
in-out property <int> activate-seq: 0;
|
||||
}
|
||||
|
||||
// Artist detail screen state.
|
||||
// One label row in the artist network sidebar's Labels section.
|
||||
export struct LabelEntry {
|
||||
@@ -4145,6 +4203,9 @@ export struct QueueItem {
|
||||
explicit: bool,
|
||||
// From an ephemeral folder — gates persistence actions in the row menu.
|
||||
is-ephemeral: bool,
|
||||
// #442 section header drawn ABOVE this row: "" | "next-in-queue" (first
|
||||
// hand-queued row) | "next-up" (first row after the manual block).
|
||||
section: string,
|
||||
}
|
||||
|
||||
// ImmersiveView audio visualizers. Driven at ~30fps by the Rust drain
|
||||
@@ -4772,6 +4833,19 @@ export global QueueState {
|
||||
in property <int> page-start: 0;
|
||||
// Index of the last upcoming entry shown, 1-based, for the counter.
|
||||
in property <int> page-end: 0;
|
||||
// #442 section headers present in the current page (0-2: "Next in queue"
|
||||
// and/or "Next up") — the sidebar adds their height to the rows block.
|
||||
in property <int> page-section-count: 0;
|
||||
// #442 drag-math aids: the "Next in queue" header sits above the page's
|
||||
// first row, and the page-local index of the "Next up" row (-1 = not in
|
||||
// this page).
|
||||
in property <bool> page-manual-header: false;
|
||||
in property <int> page-manual-pos: -1;
|
||||
// #442 menu-hint numbers (TrackContextMenu "where does it land" suffix):
|
||||
// total queue length, current index (-1 = none), manual-block size.
|
||||
in property <int> queue-length: 0;
|
||||
in property <int> queue-current-index: -1;
|
||||
in property <int> manual-count: 0;
|
||||
|
||||
// --- History tab -----------------------------------------------------
|
||||
in property <[QueueItem]> history: [];
|
||||
@@ -5072,6 +5146,11 @@ export global FavoritesState {
|
||||
in property <[AlbumCardItem]> albums-visible: []; // after search + sort (flat render)
|
||||
in property <[DiscoverSection]> albums-grouped: []; // group-by sections
|
||||
in-out property <string> albums-view-mode: "grid"; // "grid" | "list"
|
||||
// Albums LIST-mode multi-select (owner 2026-07-24): checkbox column on
|
||||
// the rows + the bulk bar (un-favorite / play-next / queue). List only —
|
||||
// switching to grid forces it off.
|
||||
in-out property <bool> albums-multi-select: false;
|
||||
in property <int> albums-selected-count: 0;
|
||||
in-out property <string> albums-search: "";
|
||||
in-out property <string> albums-sort-by: "default"; // default|title-asc|title-desc|artist-asc
|
||||
in-out property <string> albums-group-mode: "off"; // off|alpha|artist
|
||||
@@ -5132,7 +5211,15 @@ export global FavoritesActions {
|
||||
callback shuffle-tracks();
|
||||
callback toggle-multi-select();
|
||||
callback bulk-action(string /* select-all | remove-selected | clear */);
|
||||
// Albums list-mode multi-select: toggle the edit mode + the bulk bar
|
||||
// actions (select-all | play-next | queue | remove-selected | clear).
|
||||
callback albums-toggle-multi-select();
|
||||
callback albums-bulk-action(string);
|
||||
callback album-action(string /* album id */, string /* action */);
|
||||
// Generic artist action route (the All feed's ArtistGridCard follow
|
||||
// button) — forwarded to the shared media-action ("artist", id, action)
|
||||
// arm, which owns the optimistic flip + rollback.
|
||||
callback artist-action(string /* artist id */, string /* action */);
|
||||
callback albums-search(string /* query */);
|
||||
callback search-labels(string /* query */);
|
||||
callback playlists-set-sub-tab(string /* favorites | following */);
|
||||
@@ -5209,9 +5296,14 @@ export global LibraryAllState {
|
||||
in-out property <bool> show-purchases: true;
|
||||
in-out property <bool> show-favorites: true;
|
||||
in-out property <bool> show-following: true;
|
||||
// "show local" — includes local files + Plex (Phase 2). Off until the
|
||||
// local-favorites layer lands.
|
||||
in-out property <bool> show-local: false;
|
||||
// "show local" — includes local files + Plex (the local-favorites layer).
|
||||
// ON by default (users expect their local items in All); toggled from
|
||||
// Settings > Local Library, persisted in favorites_ui.json.
|
||||
in-out property <bool> show-local: true;
|
||||
// What "local" means in the feed: "favorites" (hearted items only) |
|
||||
// "all" (the whole local library). Display state for the Settings
|
||||
// selector; the loader reads the persisted pref directly.
|
||||
in-out property <string> local-scope: "favorites";
|
||||
in property <int> total: 0; // badge count (deduped union)
|
||||
in property <bool> loading: false;
|
||||
in property <string> load-error: "";
|
||||
@@ -5223,6 +5315,14 @@ export global LibraryAllActions {
|
||||
callback set-sort(string /* date | title | artist */);
|
||||
callback set-view(string /* grid | list */);
|
||||
callback toggle-source(string /* purchases | favorites | following | local */);
|
||||
// Settings > Local Library: what "local" means in the feed —
|
||||
// "favorites" (hearted items only) | "all" (the whole local library).
|
||||
// Reloads the feed.
|
||||
callback set-local-scope(string);
|
||||
// Viewport windowing: the grid/list report the mounted row band (item
|
||||
// indices, prefetch margin included) so Rust dispatches covers only for
|
||||
// the visible window and evicts far outside it.
|
||||
callback window-changed(int /* first index */, int /* last index */);
|
||||
callback open-item(string /* kind */, string /* id */);
|
||||
callback media-action(string /* kind */, string /* id */, string /* action */);
|
||||
callback retry();
|
||||
|
||||
@@ -58,7 +58,14 @@ reqwest = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
urlencoding = "2"
|
||||
open = "5"
|
||||
arboard = "3"
|
||||
# Steam Deck plan PR 3: native gamepad nav (D-pad/stick/A/B → synthetic Slint
|
||||
# keys). Pure Rust, evdev — no C deps.
|
||||
gilrs = "0.11.2"
|
||||
# wayland-data-control: Wayland-native clipboard via the data-control
|
||||
# protocols (GNOME/KDE/sway/niri/Hyprland), falling back to X11 when Wayland
|
||||
# is absent (#514 — without it arboard is X11-only and every copy fails on
|
||||
# Wayland-only sessions).
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
# async-std (rfd's name for the async-io/ashpd backend), NOT tokio: zbus 5's
|
||||
# "tokio" feature unifies graph-wide and breaks accesskit/atspi/ksni threads
|
||||
# (see the ashpd comment in qbz-credentials/Cargo.toml).
|
||||
|
||||
+531
-46
@@ -62,6 +62,8 @@ mod library_by_artist;
|
||||
mod library_by_label;
|
||||
mod link_resolver;
|
||||
mod miniplayer;
|
||||
mod gamepad;
|
||||
mod steam_osk;
|
||||
mod location_view;
|
||||
mod mix;
|
||||
mod musician;
|
||||
@@ -249,13 +251,15 @@ fn init_shell_for_user(
|
||||
// Create the system tray from this user's persisted settings (gated by
|
||||
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
|
||||
// service runs on its own thread; macOS/Windows are no-ops until the
|
||||
// tray-icon slice lands.
|
||||
// tray-icon slice lands. Suppressed under gamescope (Steam Deck Game
|
||||
// Mode): extra mapped windows / tray surfaces can steal gamescope's
|
||||
// focused-window pick (Steam Deck plan PR 1).
|
||||
tray::init(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
tokio::runtime::Handle::current(),
|
||||
tray.tray_icon_theme.clone(),
|
||||
tray.enable_tray,
|
||||
tray.enable_tray && !in_gamescope(),
|
||||
);
|
||||
|
||||
// System media controls — MPRIS on Linux (publishes DesktopEntry so GNOME
|
||||
@@ -2230,6 +2234,13 @@ fn toggle_track_favorite(
|
||||
handle: tokio::runtime::Handle,
|
||||
id: String,
|
||||
) {
|
||||
// Local/Plex rows route to the local-favorites store — their ids are file
|
||||
// paths / plex keys, dead on the Qobuz favorite API (works offline too).
|
||||
if let Some(w) = weak.upgrade() {
|
||||
if library_all::toggle_local_feed_favorite(&w, "track", &id).is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if offline_mode::engine().is_offline() {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
toast::info(&w, "Not available offline");
|
||||
@@ -2246,6 +2257,8 @@ fn toggle_track_favorite(
|
||||
}
|
||||
if let Some(w) = weak.upgrade() {
|
||||
set_row_favorite(&w, &id, make_fav);
|
||||
// Library "All" carries its own item struct — flip its heart too.
|
||||
library_all::set_feed_favorite(&w, "track", &id, make_fav);
|
||||
}
|
||||
handle.spawn(async move {
|
||||
let res = if make_fav {
|
||||
@@ -2270,6 +2283,7 @@ fn toggle_track_favorite(
|
||||
}
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
set_row_favorite(&w, &id, was_fav);
|
||||
library_all::set_feed_favorite(&w, "track", &id, was_fav);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4280,15 +4294,13 @@ fn navigate_library_all(
|
||||
});
|
||||
match library_all::load_library_all(&runtime).await {
|
||||
Ok(feed) => {
|
||||
let weak_j = weak.clone();
|
||||
let ic = image_cache.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
// Reset the windowed artwork pipeline (gen bump + cache
|
||||
// ctx) BEFORE the new set lands; the derive inside apply
|
||||
// then dispatches covers for the current window only.
|
||||
library_all::begin_library_all_artwork(ic.clone());
|
||||
library_all::apply_library_all(&w, feed);
|
||||
let jobs = library_all::artwork_jobs(&w);
|
||||
// Mixed payload (Qobuz http / local fs / Plex /library/) —
|
||||
// route each cover by scheme so local/Plex covers decode.
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak_j.clone(), ic.clone());
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -4868,6 +4880,63 @@ fn reconcile_sidebar_after_rename(
|
||||
});
|
||||
}
|
||||
|
||||
/// Sidebar refresh after a playlist IMPORT (owner 2026-07-24): the
|
||||
/// authoritative `getUserPlaylists` read lags the create by seconds, so a
|
||||
/// single immediate reload shows nothing new (the user had to refresh
|
||||
/// manually). Optimistically insert the imported row NOW, then bounded-retry
|
||||
/// the load until the API lists the imported ids — the rename reconcile's
|
||||
/// schedule, with the optimistic row re-inserted if the API never catches up
|
||||
/// inside the window (a later natural load reconciles it for real).
|
||||
fn reconcile_sidebar_after_import(
|
||||
runtime: Arc<AppRuntime<SlintAdapter>>,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: &tokio::runtime::Handle,
|
||||
ids: Vec<u64>,
|
||||
name: String,
|
||||
tracks_count: u32,
|
||||
) {
|
||||
const MAX_ATTEMPTS: u32 = 6;
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Optimistic insert NOW (single-playlist imports only — multi-part
|
||||
// imports get their per-part names from the API once it catches up).
|
||||
if ids.len() == 1 {
|
||||
let n = name.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
sidebar::insert_qobuz_entry(&w, ids[0], &n, tracks_count);
|
||||
});
|
||||
}
|
||||
handle.spawn(async move {
|
||||
let mut attempt = 0u32;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let data = sidebar::load(&runtime).await;
|
||||
let present = ids
|
||||
.iter()
|
||||
.all(|id| data.playlists.iter().any(|p| p.id == *id));
|
||||
if present || attempt >= MAX_ATTEMPTS {
|
||||
if !present {
|
||||
log::warn!(
|
||||
"[playlist-import] sidebar list still missing the imported playlist \
|
||||
after {attempt} attempts; the optimistic row stays until the next load"
|
||||
);
|
||||
}
|
||||
let name2 = name.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
sidebar::apply(&w, data);
|
||||
if !present && ids.len() == 1 {
|
||||
sidebar::insert_qobuz_entry(&w, ids[0], &name2, tracks_count);
|
||||
}
|
||||
refresh_sidebar_covers(&w);
|
||||
});
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(attempt as u64)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// (Re)spawn the per-playlist micro-collage cover downloads for the
|
||||
/// current `SidebarState.entries`. Called after any rebuild that replaces
|
||||
/// the rows (load / toggle / move / sort / search), since `set_row_data`
|
||||
@@ -5192,7 +5261,18 @@ fn wire_playlist_manager(
|
||||
.global::<PlaylistManagerActions>()
|
||||
.on_set_sort(move |value| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<PlaylistManagerState>().set_sort(value);
|
||||
// PlaylistView-style toggle: re-selecting the ACTIVE
|
||||
// option flips its direction (#657 caret); a new option
|
||||
// starts at its natural direction.
|
||||
let st = w.global::<PlaylistManagerState>();
|
||||
let cur = st.get_sort().to_string();
|
||||
let asc = if cur == value.as_str() {
|
||||
!st.get_sort_asc()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
st.set_sort(value);
|
||||
st.set_sort_asc(asc);
|
||||
playlist_manager::rebuild(&w);
|
||||
refresh_pm_covers(&w);
|
||||
}
|
||||
@@ -6994,6 +7074,96 @@ pub fn renderer_decision_summary() -> (String, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// #558 render-liveness watchdog. A backend can be ALIVE yet wedged: the
|
||||
/// vendored femtovg-wgpu skip loop (Outdated surface after reconfigure, the
|
||||
/// UI-scale/resize race that never settles on the reporter's legacy NVIDIA
|
||||
/// stack) skips EVERY frame forever while the process looks healthy — and
|
||||
/// the crash-chain ladder only escalates on DEATH, so a silent wedge never
|
||||
/// degrades. Eight seconds after the window is up, read the presented-frames
|
||||
/// counter: zero frames with a visible window = re-arm the startup sentinel
|
||||
/// with the CURRENT rung's key and restart. The existing ladder in
|
||||
/// `renderer_tier_from_prefs` then walks the next rung exactly as if the
|
||||
/// wedged boot had died (auto-wgpu → alt adapter → GL → software; manual
|
||||
/// picks revert to auto), and its own toasts explain the switch. Software
|
||||
/// is the floor that cannot fail, so every wedged boot lands somewhere
|
||||
/// usable — a music player must never strand a user on a blank window
|
||||
/// debugging GPU tiers.
|
||||
fn arm_render_liveness_watchdog(window: &AppWindow) {
|
||||
// The ladder lives on Linux (macOS has no GL tier); the floor needs no
|
||||
// watchdog.
|
||||
if cfg!(not(target_os = "linux")) {
|
||||
return;
|
||||
}
|
||||
let Some(decision) = RENDERER_DECISION.get() else {
|
||||
return;
|
||||
};
|
||||
let tier = decision.tier;
|
||||
if tier == "software" {
|
||||
return;
|
||||
}
|
||||
let weak = window.as_weak();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(8));
|
||||
if i_slint_renderer_femtovg::frames_presented() > 0 {
|
||||
return;
|
||||
}
|
||||
// The window may legitimately present nothing while hidden (closed
|
||||
// to tray inside the 8s window) — never degrade on a false positive.
|
||||
// (upgrade_in_event_loop's closure returns (), so the read rides an
|
||||
// Arc slot rather than the Result.)
|
||||
let visible = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let slot = visible.clone();
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
slot.store(
|
||||
w.window().is_visible(),
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
});
|
||||
if !visible.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Re-arm the sentinel with the current rung's key so the NEXT boot's
|
||||
// ladder walks one rung down:
|
||||
// auto wgpu -> "auto-wgpu" (next: alt adapter)
|
||||
// auto wgpu (alt) -> "auto-wgpu-alt" (next: persist GL)
|
||||
// ladder GL -> "auto-gl" (next: persist software)
|
||||
// manual wgpu/gl -> the literal key (next: revert to auto)
|
||||
let prefs = crate::ui_prefs::load();
|
||||
let sentinel_key = match prefs.renderer.as_str() {
|
||||
"auto" => {
|
||||
if WGPU_ALT_ADAPTER.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
"auto-wgpu-alt"
|
||||
} else {
|
||||
"auto-wgpu"
|
||||
}
|
||||
}
|
||||
"gl" if !prefs.renderer_auto_degraded.is_empty() => "auto-gl",
|
||||
manual => {
|
||||
// Only meaningful for the wgpu/gl manual picks; the software
|
||||
// floor never reaches the watchdog.
|
||||
manual
|
||||
}
|
||||
};
|
||||
log::warn!(
|
||||
"[renderer-watchdog] 0 presented frames 8s after window-ready on tier \
|
||||
{tier} — re-arming sentinel '{sentinel_key}' and restarting so the \
|
||||
ladder walks one rung (#558)"
|
||||
);
|
||||
arm_renderer_sentinel(sentinel_key);
|
||||
// Restart into the next rung. The delayed detached re-spawn keeps the
|
||||
// single-instance guard from seeing this process as primary (the child
|
||||
// must start AFTER we have fully exited).
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut cmd = format!("sleep 1; exec '{}'", exe.display());
|
||||
for a in std::env::args().skip(1) {
|
||||
cmd.push_str(&format!(" '{}'", a.replace('\'', "'\\''")));
|
||||
}
|
||||
let _ = std::process::Command::new("/bin/sh").arg("-c").arg(&cmd).spawn();
|
||||
}
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Startup auto-revert sentinel for a persisted (non-auto) renderer override.
|
||||
/// Armed BEFORE the risky backend init, disarmed a few seconds after the main
|
||||
/// window is up. If it survives to the next start, that start reverts the
|
||||
@@ -8036,11 +8206,25 @@ pub(crate) fn restore_main_window_geometry(window: &AppWindow) {
|
||||
if has_saved_size || ui_scale_factor > 1.0 {
|
||||
let mut w = if has_saved_size { prefs.window_width } else { 1180.0 };
|
||||
let mut h = if has_saved_size { prefs.window_height } else { 760.0 };
|
||||
let slint_scale = (window.window().scale_factor() as f64).max(0.01);
|
||||
window.window().with_winit_window(|win| {
|
||||
if let Some(mon) = win.current_monitor() {
|
||||
let avail_w = (mon.size().width as f64 / slint_scale) as f32;
|
||||
let avail_h = (mon.size().height as f64 / slint_scale) as f32;
|
||||
// Effective Slint scale for the clamp: NEVER
|
||||
// `window.scale_factor()` here — pre-creation on X11 it
|
||||
// reports the X11/XWayland DPI (Xft.dpi), which can be
|
||||
// wildly different from the monitor the window actually
|
||||
// lands on (#654: the clamp shrank restored windows by that
|
||||
// bogus factor and the Resized handler then re-saved the
|
||||
// shrunken size — geometry "lost" on every restart).
|
||||
// Default: the TARGET monitor's real DPR. Preset active:
|
||||
// Slint's scale is last_dpr × preset (the env override
|
||||
// ignores the compositor DPR entirely — see main()).
|
||||
let eff_scale = if ui_scale_factor > 1.0 {
|
||||
prefs.last_dpr.max(0.5) as f64 * ui_scale_factor as f64
|
||||
} else {
|
||||
mon.scale_factor()
|
||||
};
|
||||
let avail_w = (mon.size().width as f64 / eff_scale) as f32;
|
||||
let avail_h = (mon.size().height as f64 / eff_scale) as f32;
|
||||
if avail_w >= min_logical_w {
|
||||
w = w.min(avail_w);
|
||||
}
|
||||
@@ -8081,7 +8265,69 @@ pub(crate) fn restore_main_window_geometry(window: &AppWindow) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Steam Deck Game Mode (gamescope) quirks — PR 1 of the Steam Deck plan
|
||||
/// (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5). Runs at the
|
||||
/// very top of main(), BEFORE any Slint/winit initialization: it rewrites
|
||||
/// the environment those layers read.
|
||||
///
|
||||
/// In Game Mode QBZ runs as an XWayland client inside gamescope, which
|
||||
/// exports DISPLAY + GAMESCOPE_WAYLAND_DISPLAY but NOT WAYLAND_DISPLAY.
|
||||
/// winit's X11 DPI heuristic invents bogus scale factors on the Deck panel
|
||||
/// (winit #2401) and gamescope's focused-window scaling inverse-transforms
|
||||
/// pointer/touch through that scale (gamescope #1208/#1242) — clicks land
|
||||
/// offset. The fix is an identity transform: X11 backend + scale 1 +
|
||||
/// fullscreen kiosk, with UI legibility coming from QBZ's own ui_scale
|
||||
/// preset, never the OS scale factor.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_gamescope_quirks() -> bool {
|
||||
let detected = std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
|| std::env::var("XDG_CURRENT_DESKTOP")
|
||||
.map(|v| v.to_ascii_lowercase().contains("gamescope"))
|
||||
.unwrap_or(false)
|
||||
|| std::env::var("XDG_SESSION_DESKTOP")
|
||||
.map(|v| v.to_ascii_lowercase().contains("gamescope"))
|
||||
.unwrap_or(false);
|
||||
if !detected {
|
||||
return false;
|
||||
}
|
||||
// winit's Wayland backend under gamescope produces an invisible window
|
||||
// (winit #4152) — force the X11 path.
|
||||
std::env::remove_var("WAYLAND_DISPLAY");
|
||||
// Identity transform; a user-set value always wins.
|
||||
if std::env::var_os("WINIT_X11_SCALE_FACTOR").is_none() {
|
||||
std::env::set_var("WINIT_X11_SCALE_FACTOR", "1");
|
||||
}
|
||||
// Appliance defaults: kiosk profile + fullscreen (an explicit user
|
||||
// choice always wins). No hardcoded 1280×800 — fullscreen adapts when
|
||||
// docked.
|
||||
if std::env::var_os("QBZ_PROFILE").is_none() {
|
||||
std::env::set_var("QBZ_PROFILE", "kiosk");
|
||||
}
|
||||
if std::env::var_os("QBZ_KIOSK_FULLSCREEN").is_none() {
|
||||
std::env::set_var("QBZ_KIOSK_FULLSCREEN", "1");
|
||||
}
|
||||
// Marker for the later gates (tray + miniplayer suppression) — extra
|
||||
// mapped windows can steal gamescope's focused-window pick.
|
||||
std::env::set_var("QBZ_GAMESCOPE", "1");
|
||||
true
|
||||
}
|
||||
|
||||
/// True when running under gamescope (Steam Deck Game Mode). Stateless:
|
||||
/// `apply_gamescope_quirks` sets the marker at boot, and the raw
|
||||
/// `GAMESCOPE_WAYLAND_DISPLAY` env is inherited by the watchdog's re-spawn.
|
||||
pub(crate) fn in_gamescope() -> bool {
|
||||
std::env::var_os("QBZ_GAMESCOPE").is_some()
|
||||
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Steam Deck Game Mode (gamescope) quirks — PR 1 of the Steam Deck plan
|
||||
// (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5). FIRST: it
|
||||
// rewrites the environment (WAYLAND_DISPLAY / WINIT_X11_SCALE_FACTOR /
|
||||
// kiosk profile + fullscreen) before the scale block, the logger, or ANY
|
||||
// Slint/winit initialization reads it.
|
||||
#[cfg(target_os = "linux")]
|
||||
let gamescope = apply_gamescope_quirks();
|
||||
// UI SCALE PRESET — must run FIRST: SLINT_SCALE_FACTOR has to be in the
|
||||
// environment before the backend/window exist (winit reads it at window
|
||||
// creation), and `set_var` must run before any thread spawns (the tokio
|
||||
@@ -8104,6 +8350,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// file, all redacted at the write choke point. Feeds the in-app log viewer and
|
||||
// the diagnostics bundle. Honours RUST_LOG (default "info").
|
||||
qbz_log::install("info");
|
||||
#[cfg(target_os = "linux")]
|
||||
if gamescope {
|
||||
log::info!(
|
||||
"[gamescope] Steam Game Mode detected: X11 backend, scale 1, kiosk fullscreen, tray+miniplayer suppressed"
|
||||
);
|
||||
}
|
||||
if ui_scale_factor != 1.0 {
|
||||
log::info!(
|
||||
"[ui-scale] preset factor {ui_scale_factor} -> SLINT_SCALE_FACTOR={}",
|
||||
@@ -8234,6 +8486,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Event-loop responsiveness watchdog (#555): background probe thread,
|
||||
// read by the Diagnostics panel. Detection only — never switches tiers.
|
||||
ui_watchdog::spawn();
|
||||
// Gamepad nav (Steam Deck plan PR 3): gilrs thread feeding synthetic
|
||||
// arrow/Enter/Escape keys into PR 2's kiosk FocusScope. Only on the
|
||||
// kiosk profile or when forced — a desktop session has no use for it.
|
||||
if kiosk_profile_active() || std::env::var_os("QBZ_GAMEPAD").is_some() {
|
||||
gamepad::spawn(window.as_weak());
|
||||
}
|
||||
// Steam OSK (Steam Deck plan PR 4): open/close the Steam keyboard as text
|
||||
// fields gain/lose focus. The gate (SteamDeck env / QBZ_STEAM_OSK) is
|
||||
// checked inside, so this is always safe to wire.
|
||||
window.global::<UiFocusState>().on_osk(move |focused, mode| {
|
||||
steam_osk::focus_changed(focused, mode);
|
||||
});
|
||||
// Persist the REAL compositor DPR as `last_dpr` once the surface is
|
||||
// mapped (winit reports the true value there — unlike right after
|
||||
// creation on Wayland). Read from the WINIT window, not the Slint one:
|
||||
@@ -8489,6 +8753,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
appearance.set_local_library_track_artwork(prefs.local_library_track_artwork);
|
||||
appearance.set_in_app_toasts(prefs.in_app_toasts);
|
||||
appearance.set_theme_filter(prefs.theme_filter);
|
||||
appearance.set_play_indicator_animation(prefs.play_indicator_animation);
|
||||
appearance.set_invert_swipe_navigation(prefs.invert_swipe_navigation);
|
||||
window
|
||||
.global::<SidebarState>()
|
||||
.set_playlist_collage(prefs.sidebar_playlist_collage);
|
||||
@@ -10948,6 +11214,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
prefs.in_app_toasts = value;
|
||||
crate::ui_prefs::save(&prefs);
|
||||
}
|
||||
"play-indicator-animation" => {
|
||||
let mut prefs = crate::ui_prefs::load();
|
||||
prefs.play_indicator_animation = value;
|
||||
crate::ui_prefs::save(&prefs);
|
||||
}
|
||||
"invert-swipe-navigation" => {
|
||||
let mut prefs = crate::ui_prefs::load();
|
||||
prefs.invert_swipe_navigation = value;
|
||||
crate::ui_prefs::save(&prefs);
|
||||
}
|
||||
other => log::debug!("[qbz-slint] unhandled appearance-bool '{other}'"),
|
||||
});
|
||||
let theme_weak = window.as_weak();
|
||||
@@ -11427,7 +11703,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
("npb-view", "miniplayer") => {
|
||||
crate::miniplayer::enter();
|
||||
// Suppressed under gamescope (Steam Deck Game Mode PR 1):
|
||||
// an extra mapped window can steal the focused-window pick.
|
||||
if !in_gamescope() {
|
||||
crate::miniplayer::enter();
|
||||
}
|
||||
}
|
||||
// Large (mode 3) — docks the cover + spectrum at the bottom of
|
||||
// the OPEN sidebar, so force the sidebar to its open state before
|
||||
@@ -11733,6 +12013,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
id.clone(),
|
||||
),
|
||||
("album", "favorite") => {
|
||||
// Local/Plex feed rows route to the local-favorites store —
|
||||
// group keys are dead on the Qobuz favorite API.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
if library_all::toggle_local_feed_favorite(&w, "album", &id).is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Album-card heart + "…" menu entry: a TRUE TOGGLE keyed
|
||||
// off the favorite-album cache (filled heart → remove,
|
||||
// empty → add), mirroring the header "favorite-toggle"
|
||||
@@ -11748,6 +12035,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let new_state = !was_fav;
|
||||
if let Some(w) = weak.upgrade() {
|
||||
set_album_row_favorite(&w, &id, new_state);
|
||||
// The Library "All" feed has its own item struct — it
|
||||
// doesn't ride set_album_row_favorite's AlbumCardItem
|
||||
// flips, so flip its heart here too.
|
||||
library_all::set_feed_favorite(&w, "album", &id, new_state);
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
@@ -11789,6 +12080,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// pre-click state.
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
set_album_row_favorite(&w, &album_id, was_fav);
|
||||
library_all::set_feed_favorite(
|
||||
&w, "album", &album_id, was_fav,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11978,6 +12272,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
}
|
||||
("track", "play-later") => {
|
||||
// #442 "Play later" — the end of the manual block (after
|
||||
// every play-next / play-later already queued), with the
|
||||
// same source-typed seam as play-next.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
if snapshot_detail_open(&w) {
|
||||
if let Some(qt) = local_playlist::queue_track_for_row(&id) {
|
||||
if matches!(qt.source.as_deref(), Some("local") | Some("plex")) {
|
||||
playback::enqueue_queue_tracks_later(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
vec![qt],
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
playback::play_track_later(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
track_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
("track", "favorite") => {
|
||||
// Offline guard + optimistic toggle + network flip with
|
||||
// rollback — shared with the library-surface favorite
|
||||
@@ -12554,6 +12876,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
id.clone(),
|
||||
),
|
||||
("artist", "follow") => {
|
||||
// Local artists are name-keyed — the local-favorites store,
|
||||
// never the Qobuz API (the parse below would just no-op).
|
||||
if let Some(w) = weak.upgrade() {
|
||||
if library_all::toggle_local_feed_favorite(&w, "artist", &id).is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Toggle the artist follow (= Qobuz artist favorite). State
|
||||
// source = the in-memory artist fav cache (seeded by search +
|
||||
// the artist page). Optimistic flip on the cache + every
|
||||
@@ -12564,6 +12893,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let make = !following;
|
||||
crate::fav_cache::set_artist(aid, make);
|
||||
search::mark_artist_followed(&w, &id, make);
|
||||
// Library "All" carries its own item struct — flip its
|
||||
// follow state too.
|
||||
library_all::set_feed_favorite(&w, "artist", &id, make);
|
||||
let ast = w.global::<ArtistState>();
|
||||
if ast.get_id().as_str() == id.as_str() {
|
||||
ast.set_is_following(make);
|
||||
@@ -12593,6 +12925,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
crate::fav_cache::set_artist(aid, following);
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
search::mark_artist_followed(&w, &artist_id, following);
|
||||
library_all::set_feed_favorite(
|
||||
&w,
|
||||
"artist",
|
||||
&artist_id,
|
||||
following,
|
||||
);
|
||||
let ast = w.global::<ArtistState>();
|
||||
if ast.get_id().as_str() == artist_id.as_str() {
|
||||
ast.set_is_following(following);
|
||||
@@ -12915,6 +13253,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
("album", "toggle-select") => {
|
||||
// Albums LIST-mode multi-select (Favorites): flip the row
|
||||
// across the flat + grouped models.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
favorites::toggle_select_album(&w, &id);
|
||||
}
|
||||
}
|
||||
("track", "toggle-select") => {
|
||||
// Plain / Ctrl+Click = single per-row toggle; Shift+Click =
|
||||
// additive range from the per-surface anchor to the clicked
|
||||
@@ -17259,7 +17604,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if genre_filter::current_context() == "library-all" {
|
||||
let runtime_f = runtime.clone();
|
||||
let weak_f = weak.clone();
|
||||
let image_cache_f = image_cache.clone();
|
||||
let id_f = id.to_string();
|
||||
handle.spawn(async move {
|
||||
if !was_selected {
|
||||
@@ -17269,16 +17613,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let _ = weak_f.upgrade_in_event_loop(move |w| {
|
||||
genre_filter::apply_state(&w);
|
||||
// derive re-dispatches covers for the window itself.
|
||||
library_all::derive(&w);
|
||||
let jobs = library_all::artwork_jobs(&w);
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_search_loads(
|
||||
jobs,
|
||||
plex.base_url,
|
||||
plex.token,
|
||||
w.as_weak(),
|
||||
image_cache_f.clone(),
|
||||
);
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -17413,16 +17749,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
genre_filter::apply_state(&w);
|
||||
if genre_filter::current_context() == "library-all" {
|
||||
// derive re-dispatches covers for the window itself.
|
||||
library_all::derive(&w);
|
||||
let jobs = library_all::artwork_jobs(&w);
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_search_loads(
|
||||
jobs,
|
||||
plex.base_url,
|
||||
plex.token,
|
||||
w.as_weak(),
|
||||
image_cache.clone(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if genre_filter::current_context() == "favorites" {
|
||||
@@ -21413,7 +21741,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if summary.matched_tracks > 0 {
|
||||
toast::show(&w, "Playlist imported", ToastKind::Success);
|
||||
}
|
||||
load_sidebar_playlists(r2.clone(), weak2.clone(), &h2);
|
||||
// Sidebar refresh with API-lag tolerance:
|
||||
// optimistic row now, bounded-retry reload
|
||||
// until the API lists the new playlist.
|
||||
reconcile_sidebar_after_import(
|
||||
r2.clone(),
|
||||
weak2.clone(),
|
||||
&h2,
|
||||
summary.qobuz_playlist_ids.clone(),
|
||||
summary.playlist_name.clone(),
|
||||
summary.matched_tracks as u32,
|
||||
);
|
||||
if let Some(first) = summary.qobuz_playlist_ids.first() {
|
||||
// Navigate only while the modal is still
|
||||
// open AND this run is current (§1.8).
|
||||
@@ -21884,31 +22222,36 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// All-feed ArtistGridCard follow button → the shared ("artist", …)
|
||||
// media-action arm (optimistic flip + rollback live there).
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<FavoritesActions>()
|
||||
.on_artist_action(move |id, action| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.invoke_media_action("artist".into(), id, action);
|
||||
}
|
||||
});
|
||||
}
|
||||
// ── Library "All" mixed feed — toolbar handlers ──
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<LibraryAllActions>().on_search(move |q| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<LibraryAllState>().set_search(q);
|
||||
// derive re-dispatches covers for the current window itself.
|
||||
library_all::derive(&w);
|
||||
let jobs = library_all::artwork_jobs(&w);
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak.clone(), image_cache.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window.global::<LibraryAllActions>().on_set_sort(move |key| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
// Re-selecting the active field toggles asc/desc (PlaylistView
|
||||
// pattern); a new field resets to its natural direction.
|
||||
library_all::set_sort(&w, key.as_str());
|
||||
let jobs = library_all::artwork_jobs(&w);
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak.clone(), image_cache.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -21922,7 +22265,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<LibraryAllActions>()
|
||||
.on_toggle_source(move |which| {
|
||||
@@ -21932,13 +22274,54 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"purchases" => st.set_show_purchases(!st.get_show_purchases()),
|
||||
"favorites" => st.set_show_favorites(!st.get_show_favorites()),
|
||||
"following" => st.set_show_following(!st.get_show_following()),
|
||||
"local" => st.set_show_local(!st.get_show_local()),
|
||||
"local" => {
|
||||
st.set_show_local(!st.get_show_local());
|
||||
// The toolbar filter is the persisted one (owner
|
||||
// 2026-07-24: the toggle stays in the view).
|
||||
favorites_prefs::save(&w);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
library_all::derive(&w);
|
||||
let jobs = library_all::artwork_jobs(&w);
|
||||
let plex = crate::plex_settings::get();
|
||||
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak.clone(), image_cache.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Settings > Local Library: the local SCOPE of the feed (favorites ⇄
|
||||
// whole local library). Persist + reload the feed.
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<LibraryAllActions>()
|
||||
.on_set_local_scope(move |scope| {
|
||||
let scope = if scope.as_str() == "all" {
|
||||
"all"
|
||||
} else {
|
||||
"favorites"
|
||||
};
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<LibraryAllState>().set_local_scope(scope.into());
|
||||
}
|
||||
favorites_prefs::set_local_scope(scope);
|
||||
navigate_library_all(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
&handle,
|
||||
image_cache.clone(),
|
||||
);
|
||||
});
|
||||
}
|
||||
{
|
||||
// Windowed grid/list: dispatch covers for the reported row band and
|
||||
// evict the ones far outside it.
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<LibraryAllActions>()
|
||||
.on_window_changed(move |first, last| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
library_all::window_changed(&w, first, last);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -21989,6 +22372,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.on_albums_set_view(move |v| {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<FavoritesState>().set_albums_view_mode(v);
|
||||
// Multi-select is list-mode only — leaving it clears it.
|
||||
if w.global::<FavoritesState>().get_albums_view_mode().as_str() != "list" {
|
||||
favorites::set_albums_multi_select(&w, false);
|
||||
}
|
||||
// Switching to the (non-windowed) list view needs covers
|
||||
// the grid's window may have evicted.
|
||||
favorites::albums_view_mode_changed(&w);
|
||||
@@ -21996,6 +22383,101 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Albums LIST-mode multi-select: enter/leave the edit mode.
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<FavoritesActions>()
|
||||
.on_albums_toggle_multi_select(move || {
|
||||
if let Some(w) = weak.upgrade() {
|
||||
let on = !w.global::<FavoritesState>().get_albums_multi_select();
|
||||
favorites::set_albums_multi_select(&w, on);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Albums LIST-mode bulk bar: select-all / play-next / queue /
|
||||
// remove-selected (bulk un-favorite) / clear.
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<FavoritesActions>()
|
||||
.on_albums_bulk_action(move |action| {
|
||||
let Some(w) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
match action.as_str() {
|
||||
"select-all" => favorites::select_all_albums(&w),
|
||||
"clear" => favorites::clear_albums_selection(&w),
|
||||
"queue" => {
|
||||
for id in favorites::selected_album_ids(&w) {
|
||||
playback::enqueue_album(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
id,
|
||||
);
|
||||
}
|
||||
}
|
||||
"play-next" => {
|
||||
// Each album inserts right after the current track —
|
||||
// iterate in REVERSE so the queue order matches the
|
||||
// selection order.
|
||||
let mut ids = favorites::selected_album_ids(&w);
|
||||
ids.reverse();
|
||||
for id in ids {
|
||||
playback::enqueue_album_next(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
id,
|
||||
);
|
||||
}
|
||||
}
|
||||
"remove-selected" => {
|
||||
// Offline = read-only hearts (same rule as tracks).
|
||||
if offline_mode::engine().is_offline() {
|
||||
toast::info(&w, "Not available offline");
|
||||
return;
|
||||
}
|
||||
let ids = favorites::selected_album_ids(&w);
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
let weak = weak.clone();
|
||||
let handle = handle.clone();
|
||||
let image_cache = image_cache.clone();
|
||||
handle.clone().spawn(async move {
|
||||
for id in &ids {
|
||||
if let Err(e) =
|
||||
runtime.core().remove_favorite("album", id).await
|
||||
{
|
||||
log::error!(
|
||||
"[qbz-slint] bulk remove favorite album {id} failed: {e}"
|
||||
);
|
||||
}
|
||||
crate::fav_cache::set_album(id, false);
|
||||
}
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
favorites::set_albums_multi_select(&w, false);
|
||||
});
|
||||
navigate_favorites(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
&handle,
|
||||
image_cache.clone(),
|
||||
favorites::FavTab::Albums,
|
||||
"albums",
|
||||
);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
// Windowed albums grid: dispatch covers for the reported row band
|
||||
// and evict the ones far outside it.
|
||||
@@ -22536,6 +23018,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
|
||||
log::info!("[qbz-slint] window ready");
|
||||
// #558 render-liveness watchdog: if the backend is alive but wedged (zero
|
||||
// presented frames 8s from now), self-degrade one rung and restart.
|
||||
arm_render_liveness_watchdog(&window);
|
||||
// NOT `window.run()`: that quits the event loop when the last window
|
||||
// closes, which would kill the app the moment the window hides to tray.
|
||||
// `run_event_loop_until_quit()` keeps the loop alive until an explicit
|
||||
|
||||
Reference in new issue
Block a user