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:
vicrodh committed 2026-07-25 12:05:56 -06:00
1 parent 953ba39e77
commit a8919cbadf
4 files changed
+786 -53

No files matched your search

+144 -3
View File
@@ -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"
+103 -3
View File
@@ -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();
+8 -1
View File
@@ -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
View File
@@ -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