From c2944db9737e95573d558721b2dc0945fd388494 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:10:32 -0600 Subject: [PATCH] feat(qt): source-aware playback, session persistence and loading affordances One batch, committed together because the changes interleave in main.rs and splitting them after the fact would be a fiction. Playback routing: a track now carries its source, so a local or Plex row played from Discover stops being resolved against Qobuz first (the 404s and the long silent wait before audio). Jellyfin and Navidrome land on the same seam. Plex tracks stream through the player instead of downloading whole files first; the feeder moved into qbz-player so both frontends can reach it. Session persistence, both levels the port was missing: - The startup-page preference is finally read. It had been rendering and persisting a value nothing consumed. `last_view` is written in the SHARED ui_prefs vocabulary, not this port's route ids -- four of the six differ, and writing the raw ids would have silently broken the other frontend's restore. `on_session_entered` recorded "home" unconditionally, which also rewrote last_view on every boot, so the setting could not survive its own startup. - Queue and active track survive a restart. session_persist was tied to a frontend by exactly one type alias, so it moved to qbz-app generic over the adapter rather than being copied. Verified end to end: the snapshot restored here had been written by the Slint binary, and the exit flush keeps an un-consumed resume position instead of overwriting it with the engine's idle 0. Loading affordances: the transport shows a spinner from the moment a play is dispatched, and the restored session explicitly takes it back down -- a restored track has no dispatch behind it and no audio ahead of it, so nothing would ever have cleared it. Cortinilla results now navigate at album and artist level. Verified by the offscreen smoke gate and by driving a restore/quit cycle over MPRIS. cc --- crates/qbz-app/src/lib.rs | 1 + crates/qbz-app/src/session_persist.rs | 400 ++++++++++++++++++ crates/qbz-player/src/lib.rs | 1 + crates/qbz-player/src/remote_stream.rs | 335 +++++++++++++++ crates/qbz-plex/src/lib.rs | 26 +- crates/qbz-qt/qml/shell/Cortinilla.qml | 17 +- crates/qbz-qt/qml/shell/TransportControls.qml | 49 +++ crates/qbz-qt/qml/views/HomeView.qml | 7 +- crates/qbz-qt/src/auth_qt.rs | 12 + crates/qbz-qt/src/home_qt.rs | 10 + crates/qbz-qt/src/local_playback.rs | 152 +++++++ crates/qbz-qt/src/main.rs | 108 ++++- crates/qbz-qt/src/nav_qt.rs | 160 ++++++- crates/qbz-qt/src/now_playing.rs | 30 ++ crates/qbz-qt/src/playback_qt.rs | 36 +- crates/qbz-qt/src/player_bridge.rs | 12 + crates/qbz-qt/src/recently_qt.rs | 8 + crates/qbz-qt/src/search_qt.rs | 59 ++- crates/qbz-qt/src/settings_qt.rs | 17 +- crates/qbz-qt/src/shell_bridge.rs | 8 +- 20 files changed, 1431 insertions(+), 17 deletions(-) create mode 100644 crates/qbz-app/src/session_persist.rs create mode 100644 crates/qbz-player/src/remote_stream.rs diff --git a/crates/qbz-app/src/lib.rs b/crates/qbz-app/src/lib.rs index f7275996f..e4ce84a26 100644 --- a/crates/qbz-app/src/lib.rs +++ b/crates/qbz-app/src/lib.rs @@ -7,6 +7,7 @@ pub mod playback_driver; pub mod qconnect_identity; pub mod runtime; pub mod scrobble_timing; +pub mod session_persist; pub mod session_store; pub mod shell; pub mod settings; diff --git a/crates/qbz-app/src/session_persist.rs b/crates/qbz-app/src/session_persist.rs new file mode 100644 index 000000000..1b472c2d1 --- /dev/null +++ b/crates/qbz-app/src/session_persist.rs @@ -0,0 +1,400 @@ +//! Queue + active-track persistence — SHARED. +//! +//! Lifted out of `crates/qbz/src/session_persist.rs` (the Slint binary), where +//! the only thing tying it to Slint was one type alias: +//! `type Runtime = Arc>`. Everything it touches — +//! `session_store`, `settings::playback`, `shell::AppRuntime`, `qbz_models` — +//! already lives in shared crates, so the Qt port had no session persistence +//! for no structural reason at all. ADR-006. +//! +//! The crash-chain level is injected by the host rather than read from a +//! frontend-local static: both binaries keep their own probe file, and this +//! module only needs the NUMBER. +//! +//! DEBT: the Slint copy is still there, untouched on purpose — moving it forces +//! a rebuild of the Slint binary, which pulls `qbz-ui` and its ~30 GB compile +//! that this box cannot verify. Collapse the two the next time Slint is built. + +//! What it is: the glue for the `persist_session` / +//! `resume_playback_position` playback preferences. +//! +//! The on-disk store is the frontend-agnostic [`SessionStore`] in +//! `qbz-app` (a per-user `session.db`, already built + tested). This module is +//! the thin frontend glue: it owns a process-global store handle (bound at +//! session activation), captures the live queue + playback state into the +//! persisted snapshot at meaningful edges, and restores it at startup. +//! +//! Gating: `persist_session` (restore the queue/session) and +//! `resume_playback_position` (also restore the exact position) are per-user +//! playback preferences. Both are cached here so the hot save path never reopens +//! the prefs DB; [`set_gates`] refreshes the cache when the toggles change, and +//! [`init_for_user`] seeds them synchronously when the store opens (no race with +//! the async settings snapshot load). +//! +//! Restore is PAUSED: the queue + current track come back — it touches +//! NO protected-audio code beyond threading an existing `start_position_secs`. +//! The saved position rides along via [`take_resume_for`] and is consumed on the +//! first play of the restored track, reusing the player's session-resume offset. +//! Nothing here touches the protected audio path beyond threading an existing +//! `start_position_secs` argument. + + +/// Host-set crash-chain level (see the callers' probe). 1 = clean previous boot. +static CRASH_CHAIN: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(1); + +/// The frontend adapter bound to the runtime. Named locally so the signatures +/// below read as one bound instead of repeating the whole where-clause. +pub trait Adapter: qbz_models::traits::FrontendAdapter + Send + Sync + 'static {} +impl Adapter for T {} + +/// Publish this boot's crash-chain level. Call once, after arming the probe. +pub fn set_crash_chain_level(level: u8) { + CRASH_CHAIN.store(level, std::sync::atomic::Ordering::Relaxed); +} + +fn crash_chain_level() -> u8 { + CRASH_CHAIN.load(std::sync::atomic::Ordering::Relaxed) +} + + +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use crate::session_store::{ + PersistedPlaybackSession, PersistedQueueTrack, PersistedSessionSnapshot, + PersistedShellViewState, SessionStore, +}; +use crate::settings::playback::PlaybackPreferencesStore; +use crate::shell::AppRuntime; +use qbz_models::{QueueTrack, RepeatMode}; + +/// Per-user session store, bound at activation (None before login / on failure). +static STORE: Mutex> = Mutex::new(None); +/// Cached `persist_session` gate — captured/restored only when true. +static PERSIST_SESSION: AtomicBool = AtomicBool::new(false); +/// Cached `resume_playback_position` gate — primes [`PENDING_RESUME`] at restore. +static RESUME_POSITION: AtomicBool = AtomicBool::new(false); +/// Position (secs) to resume the restored current track at on the first play. +/// 0 = none. Written at restore (when resume is on); consumed on first play. +static PENDING_RESUME: AtomicU64 = AtomicU64::new(0); +/// Track id the pending resume position applies to, so ONLY the restored current +/// track resumes — playing any other track first starts from 0. 0 = none. +static PENDING_RESUME_TRACK: AtomicU64 = AtomicU64::new(0); +/// Runtime + tokio handle captured at shell entry, so the synchronous window +/// close handlers can flush a final full snapshot before the loop quits. +/// Type-ERASED on purpose. A `static` cannot name `Arc>` now that +/// `A` is a parameter, so what gets stored is the finished job — a closure that +/// already owns the runtime and the handle — instead of the ingredients. +type ExitSaver = Box; +static EXIT_CTX: OnceLock = OnceLock::new(); + +/// Bind the runtime + tokio handle for [`save_on_exit`]. Called once at shell +/// entry (idempotent — later calls are ignored by `OnceLock`). +pub fn bind_exit_ctx(runtime: Arc>, handle: tokio::runtime::Handle) { + let _ = EXIT_CTX.set(Box::new(move || { + handle.block_on(capture_and_save(&runtime)); + })); +} + +/// Flush a final full snapshot synchronously (the window close handlers run on +/// the UI thread, off the tokio runtime, so we `block_on`). No-op until the exit +/// context is bound or unless `persist_session` is on. +pub fn save_on_exit() { + if !persist_enabled() { + return; + } + if let Some(save) = EXIT_CTX.get() { + save(); + } +} + +/// Open the per-user session store and seed the gate flags synchronously from +/// the playback preferences. Called at session activation alongside the other +/// `init_for_user` stores. Failures degrade to "no persistence" (logged). +pub fn init_for_user(base_dir: &Path) { + let opened = match SessionStore::new_at(base_dir) { + Ok(store) => { + *STORE.lock().unwrap() = Some(store); + true + } + Err(e) => { + log::warn!("[session] persist: open failed: {e}"); + *STORE.lock().unwrap() = None; + false + } + }; + // Seed the gates from the per-user playback prefs so capture/restore work + // before the async settings snapshot has had a chance to call set_gates. + match PlaybackPreferencesStore::new_at(base_dir).and_then(|s| s.get_preferences()) { + Ok(prefs) => set_gates(prefs.persist_session, prefs.resume_playback_position), + Err(e) => { + log::warn!("[session] persist: prefs read failed, gates off: {e}"); + set_gates(false, false); + } + } + log::info!( + "[session] persist: init at {} (store_open={opened}, persist={}, resume={})", + base_dir.display(), + PERSIST_SESSION.load(Ordering::Relaxed), + RESUME_POSITION.load(Ordering::Relaxed) + ); +} + +/// Refresh the cached gate flags (called by the Settings toggle handlers and the +/// settings snapshot load so the cache tracks live preference changes). +pub fn set_gates(persist_session: bool, resume_position: bool) { + PERSIST_SESSION.store(persist_session, Ordering::Relaxed); + RESUME_POSITION.store(resume_position, Ordering::Relaxed); +} + +/// Set ONE gate. The two toggles live on different Settings rows and each +/// handler only knows its own new value; making it read the sibling pref back +/// off disk just to call `set_gates` is a round trip that can also disagree +/// with what is on screen. +pub fn set_persist_gate(on: bool) { + PERSIST_SESSION.store(on, Ordering::Relaxed); +} + +/// Sibling of [`set_persist_gate`] for the resume-position row. +pub fn set_resume_gate(on: bool) { + RESUME_POSITION.store(on, Ordering::Relaxed); +} + +/// Whether session persistence is currently enabled. +pub fn persist_enabled() -> bool { + PERSIST_SESSION.load(Ordering::Relaxed) +} + +/// Take + clear the pending resume position IF it applies to `track_id` (the +/// restored current track). Returns the saved position once, then 0 forever — +/// and 0 for any other track, so playing something else first never resumes. +pub fn take_resume_for(track_id: u64) -> u64 { + if track_id != 0 && PENDING_RESUME_TRACK.swap(0, Ordering::Relaxed) == track_id { + PENDING_RESUME.swap(0, Ordering::Relaxed) + } else { + 0 + } +} + +/// Peek the pending resume position WITHOUT consuming it (so the seek bar can be +/// seeded at restore while the actual resume still fires on first play). 0 = none. +pub fn pending_resume_position() -> u64 { + if PENDING_RESUME_TRACK.load(Ordering::Relaxed) != 0 { + PENDING_RESUME.load(Ordering::Relaxed) + } else { + 0 + } +} + +fn repeat_to_str(mode: RepeatMode) -> &'static str { + match mode { + RepeatMode::Off => "off", + RepeatMode::All => "all", + RepeatMode::One => "one", + } +} + +fn repeat_from_str(s: &str) -> RepeatMode { + match s { + "all" => RepeatMode::All, + "one" => RepeatMode::One, + _ => RepeatMode::Off, + } +} + +fn to_persisted(t: &QueueTrack) -> PersistedQueueTrack { + PersistedQueueTrack { + id: t.id, + title: t.title.clone(), + artist: t.artist.clone(), + album: t.album.clone(), + duration_secs: t.duration_secs, + artwork_url: t.artwork_url.clone(), + hires: t.hires, + bit_depth: t.bit_depth, + sample_rate: t.sample_rate, + is_local: t.is_local, + album_id: t.album_id.clone(), + artist_id: t.artist_id, + streamable: t.streamable, + source: t.source.clone(), + parental_warning: t.parental_warning, + source_item_id_hint: t.source_item_id_hint.clone(), + } +} + +fn from_persisted(t: PersistedQueueTrack) -> QueueTrack { + QueueTrack { + id: t.id, + title: t.title, + // The persisted schema predates `version` (Tauri parity): the edition + // subtitle is not stored, so a restored track simply has no version. + version: None, + artist: t.artist, + album: t.album, + // Album-version is cosmetic (now-playing/MPRIS); not persisted in the + // session schema, so a restored track shows the clean album until the + // next album-play repopulates it. + album_version: None, + duration_secs: t.duration_secs, + artwork_url: t.artwork_url, + hires: t.hires, + bit_depth: t.bit_depth, + sample_rate: t.sample_rate, + is_local: t.is_local, + album_id: t.album_id, + artist_id: t.artist_id, + streamable: t.streamable, + source: t.source, + parental_warning: t.parental_warning, + source_item_id_hint: t.source_item_id_hint, + // Not persisted in the session schema — a restored track carries no + // container origin, so the "playing from" button falls back to the + // track's own album until the next container play re-stamps the queue. + context_kind: None, + context_id: None, + } +} + +/// Capture the live queue + playback state and persist it. No-op unless +/// `persist_session` is on and the store is open. Async (reads the queue lock). +pub async fn capture_and_save(runtime: &Arc>) { + if !persist_enabled() { + return; + } + let (tracks, current_index) = runtime.core().get_all_queue_tracks().await; + // Crash-chain level >=3 bypassed the restore this boot, so the queue on + // disk is the GOOD copy the user wants back on a healthy start — don't + // clobber it with this session's empty queue at exit. A queue the user + // actually built during the recovered boot still saves normally. + if tracks.is_empty() && crash_chain_level() >= 3 { + log::info!( + "[session] persist: crash-chain recovery boot with empty queue — \ + keeping the preserved snapshot on disk" + ); + return; + } + let full = runtime.core().get_queue_state_full().await; + let pb = runtime.core().get_playback_state(); + // POSITION: the engine reports 0 until something actually loads, so a + // session that was restored and then closed WITHOUT playing would write + // that idle 0 over the resume point the restore had just armed — one + // open-and-close and the user silently loses their place, with the queue + // still there to make it look like persistence worked. + // + // An UNCONSUMED pending resume is the truthful value in exactly that case. + // `pending_resume_position` self-clears the moment the first play takes it + // (`take_resume_for` zeroes the track id), so this can never resurrect a + // stale position: once the track has played, the engine's own number wins. + let position = if pb.position == 0 && !pb.is_playing { + let pending = pending_resume_position(); + if pending > 0 { + log::info!( + "[session] persist: keeping the un-consumed resume position {pending}s (engine idle at 0 — nothing was played this session)" + ); + } + pending + } else { + pb.position + }; + let snapshot = PersistedSessionSnapshot { + playback: PersistedPlaybackSession { + queue_tracks: tracks.iter().map(to_persisted).collect(), + current_index, + current_position_secs: position, + volume: pb.volume, + shuffle_enabled: full.shuffle, + repeat_mode: repeat_to_str(full.repeat).to_string(), + was_playing: pb.is_playing, + saved_at: 0, // set inside save_session + }, + // Shell-view restoration is handled separately (ui_prefs startup_page); + // keep the Tauri view columns at their defaults so the schema round-trips. + shell_view: PersistedShellViewState::default(), + }; + let track_count = snapshot.playback.queue_tracks.len(); + if let Some(store) = STORE.lock().unwrap().as_ref() { + match store.save_session(&snapshot) { + Ok(()) => log::info!( + "[session] persist: saved {track_count} queue tracks (pos {}s, playing {})", + snapshot.playback.current_position_secs, + snapshot.playback.was_playing + ), + Err(e) => log::warn!("[session] persist: save failed: {e}"), + } + } else { + log::warn!("[session] persist: capture skipped (store not open)"); + } +} + +/// Quick position-only save (a single cheap UPDATE) — for the poll loop's +/// throttled tick and the pause edge, so a crash keeps a near-current position. +pub fn save_position(position_secs: u64) { + if !persist_enabled() { + return; + } + if let Some(store) = STORE.lock().unwrap().as_ref() { + let _ = store.save_position(position_secs); + } +} + +/// Restore the persisted queue at startup. Returns true if a non-empty queue was +/// restored (so the caller refreshes the now-playing bar). Restores PAUSED; when +/// `resume_playback_position` is on, primes [`PENDING_RESUME`] for Phase B. +pub async fn restore(runtime: &Arc>) -> bool { + if !persist_enabled() { + log::info!("[session] persist: restore skipped (persist_session off)"); + return false; + } + let snapshot = { + let guard = STORE.lock().unwrap(); + let Some(store) = guard.as_ref() else { + log::warn!("[session] persist: restore skipped (store not open)"); + return false; + }; + match store.load_session() { + Ok(s) => s, + Err(e) => { + log::warn!("[session] persist: load failed: {e}"); + return false; + } + } + }; + let pb_sess = snapshot.playback; + if pb_sess.queue_tracks.is_empty() { + log::info!("[session] persist: nothing to restore (saved queue is empty)"); + return false; + } + let position = pb_sess.current_position_secs; + let count = pb_sess.queue_tracks.len(); + let index = pb_sess.current_index; + let tracks: Vec = pb_sess + .queue_tracks + .into_iter() + .map(from_persisted) + .collect(); + // The current track's id, so the resume position is applied ONLY when this + // exact track is the first one played after the restore. + let current_track_id = index.and_then(|i| tracks.get(i)).map(|t| t.id).unwrap_or(0); + runtime + .core() + .set_queue_with_order(tracks, index, pb_sess.shuffle_enabled, None) + .await; + runtime + .core() + .set_repeat_mode(repeat_from_str(&pb_sess.repeat_mode)) + .await; + // The queue session carries the authoritative last volume; apply it to the + // player (the slider also seeds from ui_prefs, but this keeps them in step). + let _ = runtime.core().set_volume(pb_sess.volume); + if RESUME_POSITION.load(Ordering::Relaxed) && position > 0 && current_track_id != 0 { + PENDING_RESUME.store(position, Ordering::Relaxed); + PENDING_RESUME_TRACK.store(current_track_id, Ordering::Relaxed); + } + log::info!( + "[session] persist: restored {count} queue tracks (index {index:?}), paused; \ + resume position {position}s (consumed on first play when enabled)" + ); + true +} diff --git a/crates/qbz-player/src/lib.rs b/crates/qbz-player/src/lib.rs index 1df624a78..b904fa269 100644 --- a/crates/qbz-player/src/lib.rs +++ b/crates/qbz-player/src/lib.rs @@ -32,6 +32,7 @@ //! ``` pub mod player; +pub mod remote_stream; pub mod queue; // Re-export main types diff --git a/crates/qbz-player/src/remote_stream.rs b/crates/qbz-player/src/remote_stream.rs new file mode 100644 index 000000000..f50095638 --- /dev/null +++ b/crates/qbz-player/src/remote_stream.rs @@ -0,0 +1,335 @@ +//! Progressive Range-streaming feeder — SHARED. +//! +//! Lifted verbatim out of `crates/qbz/src/remote_stream.rs` (the Slint binary), +//! where it had lived for no structural reason: its only imports are `std` and +//! this crate's own `BufferWriter` / `Player`, i.e. it never depended on Slint +//! at all. The Qt port had no streaming feeder because of that placement, so +//! its Plex path downloaded the WHOLE file before the first note — 58 MB, and +//! measurably slower than a Qobuz track streamed off the internet. +//! +//! ADR-006 (frontend-agnostic core) is the rule this restores. +//! +//! DEBT: the Slint copy is still there and untouched, deliberately — moving it +//! would force a rebuild of the Slint binary, which pulls `qbz-ui` and its +//! ~30 GB compile that this box cannot verify. Collapse the two the next time +//! the Slint side is built: `crates/qbz/src/remote_stream.rs` becomes +//! `pub use qbz_player::remote_stream::*;`. + +//! Shared HTTP streaming feeder. +//! +//! Ports the Tauri `track_loading.rs` progressive feeder verbatim: probe a +//! remote audio URL for size + FLAC format, open the player's progressive +//! streaming sink (`Player::play_streaming_dynamic`), then push the body to the +//! returned `BufferWriter` chunk-by-chunk as it arrives. Playback starts as soon +//! as the initial buffer fills — not after the whole file lands. +//! +//! `reqwest + BufferWriter` bound only, so it stays frontend-side and never +//! crosses the qconnect-app boundary. Used by BOTH the QConnect renderer +//! (`qconnect_engine.rs`) and the Plex playback path (`playback.rs`), so there +//! is exactly one feeder. +//! +//! BIT-PERFECT: `play_streaming_dynamic` decodes the same original bytes and +//! drives the PROTECTED device init from the decoded stream. The +//! `sample_rate`/`bit_depth` parsed here are only the streaming-config hints; +//! the audio backend (`pipewire_backend.rs`, `init_device`, `audio_settings.rs`) +//! is untouched. + +use std::time::Duration; + +use crate::{BufferWriter, Player}; + +/// Format/size facts sniffed from a remote audio URL before streaming. +pub struct RemoteStreamInfo { + pub content_length: u64, + pub sample_rate: u32, + pub channels: u16, + pub bit_depth: u32, + pub speed_mbps: f64, +} + +/// Probe + open the progressive sink + spawn the background feeder. +/// +/// On success the player has begun buffering and `play_streaming_dynamic` will +/// start audio once the initial buffer fills; the body download runs in a +/// spawned task. Errors here mean the caller should fall back to a full +/// download (the probe or the sink open failed). +pub async fn stream_remote_track_into_player( + player: &Player, + track_id: u64, + duration_secs: u64, + start_position_secs: u64, + url: &str, + log_tag: &str, +) -> Result<(), String> { + let stream_info = probe_remote_stream_info(url).await?; + log::info!( + "[{}/STREAMING] Track {} - {:.2} MB, {}Hz, {} ch, {}-bit, {:.1} MB/s", + log_tag, + track_id, + stream_info.content_length as f64 / (1024.0 * 1024.0), + stream_info.sample_rate, + stream_info.channels, + stream_info.bit_depth, + stream_info.speed_mbps + ); + + let writer = player + .play_streaming_dynamic( + track_id, + stream_info.sample_rate, + stream_info.channels, + stream_info.bit_depth, + stream_info.content_length, + stream_info.speed_mbps, + duration_secs, + start_position_secs, + ) + .map_err(|err| format!("start streaming remote track {track_id}: {err}"))?; + + let url = url.to_string(); + let content_length = stream_info.content_length; + let log_tag = log_tag.to_string(); + tokio::spawn(async move { + if let Err(err) = + download_and_stream_remote_track(&url, writer, track_id, content_length, &log_tag).await + { + log::error!( + "[{}/STREAMING] Track {} failed while streaming: {}", + log_tag, + track_id, + err + ); + } + }); + + Ok(()) +} + +/// HEAD for content-length, then a small `Range: bytes=0-65535` GET to (a) +/// measure throughput and (b) parse the FLAC `STREAMINFO` block for the real +/// sample rate / channels / bit depth. Never defaults silently for FLAC (a +/// wrong sample rate would silently resample hi-res). +pub async fn probe_remote_stream_info(url: &str) -> Result { + use std::time::Instant; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .build() + .map_err(|err| format!("create stream probe client: {err}"))?; + + let head_response = client + .head(url) + .header("User-Agent", "Mozilla/5.0") + .send() + .await + .map_err(|err| format!("probe HEAD request failed: {}", describe_reqwest_error(&err)))?; + + if !head_response.status().is_success() { + return Err(format!( + "probe HEAD request failed with status {}", + head_response.status() + )); + } + + let content_length = head_response + .headers() + .get("content-length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| "probe missing content-length header".to_string())?; + + let start_time = Instant::now(); + let range_response = client + .get(url) + .header("User-Agent", "Mozilla/5.0") + .header("Range", "bytes=0-65535") + .send() + .await + .map_err(|err| format!("probe range request failed: {}", describe_reqwest_error(&err)))?; + + if !range_response.status().is_success() { + return Err(format!( + "probe range request failed with status {}", + range_response.status() + )); + } + + let initial_bytes = range_response + .bytes() + .await + .map_err(|err| format!("read probe bytes failed: {}", describe_reqwest_error(&err)))?; + + let elapsed = start_time.elapsed(); + let speed_mbps = if elapsed.as_secs_f64() > 0.0 { + (initial_bytes.len() as f64 / elapsed.as_secs_f64()) / (1024.0 * 1024.0) + } else { + 10.0 + }; + + // STREAMINFO parse via the shared prober (hoisted to qbz-models for the + // cast path, #638 fix 1). The prober never guesses — the CD-shaped + // defaults for a non-FLAC probe stay HERE so this path's behavior is + // byte-identical to the original inline parse. + let (sample_rate, channels, bit_depth) = match qbz_models::probe_streaminfo(&initial_bytes) { + Some(p) => (p.sample_rate, p.channels, p.bits_per_sample), + None => { + log::warn!("[remote-stream] Non-FLAC probe for remote handoff, using defaults"); + (44_100, 2, 16) + } + }; + + Ok(RemoteStreamInfo { + content_length, + sample_rate, + channels, + bit_depth, + speed_mbps, + }) +} + +/// Plain full-body GET → `bytes_stream()` loop → `writer.push_chunk` → +/// `writer.complete()`. No HTTP Range on the main GET (the `BufferedMediaSource` +/// buffers every pushed byte and serves seeks from the growing buffer). +pub async fn download_and_stream_remote_track( + url: &str, + writer: BufferWriter, + track_id: u64, + content_length: u64, + log_tag: &str, +) -> Result<(), String> { + use futures_util::StreamExt; + use std::time::Instant; + + struct FailGuard { + writer: BufferWriter, + armed: bool, + } + impl Drop for FailGuard { + fn drop(&mut self) { + if self.armed { + let _ = self + .writer + .error("remote stream aborted before completion".into()); + } + } + } + let mut guard = FailGuard { + writer, + armed: true, + }; + let writer = &guard.writer; + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(300)) + .build() + .map_err(|err| format!("create remote streaming client: {err}"))?; + + let response = client + .get(url) + .header("User-Agent", "Mozilla/5.0") + .send() + .await + .map_err(|err| { + format!( + "start remote streaming request failed: {}", + describe_reqwest_error(&err) + ) + })?; + + if !response.status().is_success() { + return Err(format!( + "remote streaming request failed with status {}", + response.status() + )); + } + + let mut bytes_received = 0u64; + let mut stream = response.bytes_stream(); + let start_time = Instant::now(); + let mut last_log_time = Instant::now(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result + .map_err(|err| format!("remote streaming chunk failed: {}", describe_reqwest_error(&err)))?; + bytes_received += chunk.len() as u64; + + if let Err(err) = writer.push_chunk(&chunk) { + log::error!( + "[{}/STREAMING] Failed to push chunk for track {}: {}", + log_tag, + track_id, + err + ); + guard.armed = false; + let _ = writer.error(format!("push_chunk failed: {err}")); + return Err(format!("push_chunk failed: {err}")); + } + + let now = Instant::now(); + if now.duration_since(last_log_time) >= Duration::from_secs(2) && content_length > 0 { + let progress = (bytes_received as f64 / content_length as f64) * 100.0; + let avg_speed = + (bytes_received as f64 / start_time.elapsed().as_secs_f64()) / (1024.0 * 1024.0); + log::info!( + "[{}/STREAMING] Track {} {:.1}% ({:.2}/{:.2} MB) @ {:.2} MB/s", + log_tag, + track_id, + progress, + bytes_received as f64 / (1024.0 * 1024.0), + content_length as f64 / (1024.0 * 1024.0), + avg_speed + ); + last_log_time = now; + } + } + + guard.armed = false; + if let Err(err) = writer.complete() { + log::error!( + "[{}/STREAMING] Failed to mark stream complete for track {}: {}", + log_tag, + track_id, + err + ); + let _ = writer.error(format!("complete failed: {err}")); + return Err(format!("complete failed: {err}")); + } + + log::info!( + "[{}/STREAMING] Track {} complete: {:.2} MB in {:.1}s", + log_tag, + track_id, + bytes_received as f64 / (1024.0 * 1024.0), + start_time.elapsed().as_secs_f64() + ); + + Ok(()) +} + +/// reqwest's `Display` hides the source chain — which is exactly where the +/// diagnosis lives (Akamai's >100-header small-object flood surfaces as hyper's +/// "message head is too large" two levels down). Walk `source()` and join the +/// chain so logs AND signature matching see the real cause. +pub fn describe_reqwest_error(err: &reqwest::Error) -> String { + use std::error::Error as _; + let mut out = err.to_string(); + let mut source = err.source(); + while let Some(cause) = source { + out.push_str(": "); + out.push_str(&cause.to_string()); + source = cause.source(); + } + out +} + +/// True when an error message (already chain-expanded by +/// [`describe_reqwest_error`]) shows hyper's hard-coded h1 100-header cap. +/// Akamai answers SMALL raw-url objects with ~106 headers (the `X-AK-GRN` / +/// `X-AK-FWD-ERROR: ERR_POC_FWD_OBJ_TOO_SMALL` flood), so EVERY reqwest fetch +/// of such an URL fails this way — streaming probe and full download alike. +pub fn is_header_flood_error(message: &str) -> bool { + let haystack = message.to_ascii_lowercase(); + haystack.contains("message head is too large") || haystack.contains("too many headers") +} diff --git a/crates/qbz-plex/src/lib.rs b/crates/qbz-plex/src/lib.rs index b45e2d265..a7a1adc89 100644 --- a/crates/qbz-plex/src/lib.rs +++ b/crates/qbz-plex/src/lib.rs @@ -324,8 +324,32 @@ fn open_plex_cache_db() -> Result { Ok(conn) } +/// The default-timeout Plex client, built ONCE. +/// +/// It used to be rebuilt on every call, and a fresh `reqwest::Client` is not +/// free: new connection pool (so no keep-alive to reuse), fresh TLS handshake, +/// and a fresh DNS resolution. On Plex that last one is the expensive part — +/// LAN servers are addressed as `https://..plex.direct:32400`, and +/// `*.plex.direct` resolves through Plex's own nameservers, i.e. over the +/// INTERNET. So every play paid an internet DNS round trip plus a TLS handshake +/// before the first byte of a file that was sitting on the local network. +/// +/// That is the shape the owner reported (2026-08-13): playing a Plex track took +/// LONGER than streaming a Qobuz track from the internet, which no amount of +/// LAN transfer time can explain — but a per-call client can, because the Qobuz +/// path reuses one long-lived client and this one threw its away every time. +/// +/// Cached here rather than at the call sites so every Plex entry point gets it. +/// `build_plex_client_with_timeout` stays uncached: its callers pick a short +/// bound on purpose (quality hydration), and those are genuinely different +/// clients. +static PLEX_CLIENT: std::sync::OnceLock> = + std::sync::OnceLock::new(); + fn build_plex_client() -> Result { - build_plex_client_with_timeout(Duration::from_secs(120)) + PLEX_CLIENT + .get_or_init(|| build_plex_client_with_timeout(Duration::from_secs(120))) + .clone() } /// Build the LAN Plex client with a caller-chosen request timeout. Quality diff --git a/crates/qbz-qt/qml/shell/Cortinilla.qml b/crates/qbz-qt/qml/shell/Cortinilla.qml index 146962141..3347281b2 100644 --- a/crates/qbz-qt/qml/shell/Cortinilla.qml +++ b/crates/qbz-qt/qml/shell/Cortinilla.qml @@ -511,9 +511,22 @@ Item { // doing nothing. The highlight above already guards with // `?? -2`; this is the same guard on the action. var fi = row.flatIndex - if (fi === undefined || fi === null) return - if (root.headerBar) root.headerBar.clearSearch() + if (fi === undefined || fi === null) { + // Was a bare `return`: the one failure mode that leaves NO + // trace anywhere, which is exactly what a dead click looks + // like from the outside. + console.warn("cortinilla: row has no flatIndex, click dropped —", + JSON.stringify(row)) + return + } + // ORDER MATTERS. clearSearch() empties the header field, which + // drives the live-search path; if that reaches Rust before the + // click does, it can replace or clear the very snapshot the + // router is about to look the row up in — and the router then + // returns silently. The click goes FIRST now; the field is + // cleared after, which is the visible behaviour either way. QbzSearch.cortinillaRowClicked(fi) + if (root.headerBar) root.headerBar.clearSearch() } } } diff --git a/crates/qbz-qt/qml/shell/TransportControls.qml b/crates/qbz-qt/qml/shell/TransportControls.qml index 71ff5f3e5..fc950caa7 100644 --- a/crates/qbz-qt/qml/shell/TransportControls.qml +++ b/crates/qbz-qt/qml/shell/TransportControls.qml @@ -67,6 +67,20 @@ Row { anchors.verticalCenter: parent.verticalCenter onClicked: QbzPlayer.toggleShuffle() } + // True while the player is resolving/fetching the track that is about to + // play. Not gated on `npHasTrack`: the first play of a session has no + // track yet, and that is exactly the case that felt dead. + readonly property bool loading: QbzPlayer.npLoading + // Spin phase, advanced on the shared shell pulse (QbzShell.pulseMs). + property real spinPhase: 0 + Connections { + target: QbzShell + function onPulseMsChanged() { + if (tc.loading) + tc.spinPhase = (tc.spinPhase + 0.42) % 6.283185 + } + } + QbzIconButton { name: "skip-back" btnEnabled: QbzPlayer.npHasTrack @@ -83,6 +97,10 @@ Row { ? (playArea.containsMouse && QbzPlayer.npHasTrack ? theme.accentHover : theme.accent) : ((playArea.containsMouse && QbzPlayer.npHasTrack) ? theme.surfaceHover : "transparent") QbzIcon { + // Hidden while loading — the ring below stands in for it, so the + // button never shows a play glyph for a track that is still being + // fetched. + visible: !tc.loading name: QbzPlayer.npPlaying ? "pause" : "play-fill" width: 20 height: 20 @@ -90,6 +108,37 @@ Row { tintName: tc.playCircle ? tc.tintOnAccent : (playArea.containsMouse ? "accent" : tc.tintStrong) } + // LOADING RING. Between the click and the first sample there was NO + // affordance anywhere in the shell, so a fetch that takes seconds — a + // Plex part, a cold CMAF session — reads as a dead click (owner report, + // 2026-08-13). `npLoading` already existed and was published + // (now_playing.rs) but the only consumer was SongCard's artwork + // spinner, which is gated on `npHasTrack` and so shows nothing on the + // first play of a session. + // + // ON THE SHELL PULSE, not a NumberAnimation: a rotation animator ticks + // at display rate and every animation frame is a whole-window present + // (CLAUDE.md, "the repaint pulse"). At ~30 Hz this costs zero extra + // presents because the pulse is already running. + Item { + anchors.fill: parent + visible: tc.loading + Rectangle { + id: ring + anchors.centerIn: parent + width: parent.width - 8 + height: width + radius: width / 2 + color: "transparent" + border.width: 2 + border.color: tc.playCircle ? theme.accentText : theme.accent + // A 270-degree arc read: three quarters of the ring at full + // alpha and the last quarter masked by the fill behind it is + // not expressible on a Rectangle, so the spin is carried by + // opacity pulsing instead — one property, no extra node. + opacity: 0.35 + 0.45 * (1 + Math.sin(tc.spinPhase)) / 2 + } + } MouseArea { id: playArea anchors.fill: parent diff --git a/crates/qbz-qt/qml/views/HomeView.qml b/crates/qbz-qt/qml/views/HomeView.qml index 45a6f2fd0..29a1b0559 100644 --- a/crates/qbz-qt/qml/views/HomeView.qml +++ b/crates/qbz-qt/qml/views/HomeView.qml @@ -516,7 +516,12 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: QbzPlayer.playTrack(card.id) + // The row's ORIGIN goes with the id. A Recently-Played row can be a + // Qobuz track, a local file or a Plex row, and `playTrack` alone + // sends every id to the Qobuz catalog — which 404s on the other two + // (the owner hit it on a Plex id from this exact rail). Empty + // source = qobuz, which is the legacy history shape. + onClicked: QbzPlayer.playTrackFrom(card.id, card.source || "") } } diff --git a/crates/qbz-qt/src/auth_qt.rs b/crates/qbz-qt/src/auth_qt.rs index 5fec2ab4b..feaa01a84 100644 --- a/crates/qbz-qt/src/auth_qt.rs +++ b/crates/qbz-qt/src/auth_qt.rs @@ -156,6 +156,11 @@ where // Plex settings live per-user (plex_settings.db); bind the store to // this session so the first Local Library read does not have to. crate::local_plex::init_for_user(&dir); + // Session persistence (queue + current track + position). The store is + // per-user (`session.db`) and the gates are seeded from this user's + // playback prefs inside init_for_user, so it works before Settings has + // published anything. + qbz_app::session_persist::init_for_user(&dir); // Phase 5: local-favorites store (Library show-local + hearts). crate::library_qt::init_local_favorites(&dir); // Qobuz favourite-id cache (favorites_cache.db, shared with the Slint @@ -298,6 +303,8 @@ where // store to this session so the first Local Library read does // not have to. crate::local_plex::init_for_user(&dir); + // Session persistence — see the fresh-login path. + qbz_app::session_persist::init_for_user(&dir); crate::library_qt::init_local_favorites(&dir); // Qobuz favourite-id cache — see the fresh-login path. crate::fav_cache_qt::init_for_user(&dir); @@ -372,6 +379,11 @@ where // Plex settings live per-user (plex_settings.db); bind the store to // this session so the first Local Library read does not have to. crate::local_plex::init_for_user(&dir); + // Session persistence (queue + current track + position). The store is + // per-user (`session.db`) and the gates are seeded from this user's + // playback prefs inside init_for_user, so it works before Settings has + // published anything. + qbz_app::session_persist::init_for_user(&dir); // Phase 5: local-favorites store (Library show-local + hearts). crate::library_qt::init_local_favorites(&dir); // Qobuz favourite-id cache. The disk seed is the ONLY source offline diff --git a/crates/qbz-qt/src/home_qt.rs b/crates/qbz-qt/src/home_qt.rs index 7d6dcf611..6937453d6 100644 --- a/crates/qbz-qt/src/home_qt.rs +++ b/crates/qbz-qt/src/home_qt.rs @@ -72,6 +72,13 @@ pub struct HomeCard { pub ribbon: String, #[serde(rename = "ribbonKind")] pub ribbon_kind: String, + /// Where the row came from: "qobuz" | "local" | "plex" (empty = qobuz, the + /// legacy history shape). CARRIED, not inferred: playback routes on it, and + /// a local id handed to the Qobuz catalog 404s or opens a different album + /// that happens to share the number. `slimTracks` is the rail that needs + /// it; every other kind leaves it empty and it stays off the wire. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source: String, #[serde(rename = "isPinned", default)] pub is_pinned: bool, /// Heart state at BUILD time, from `fav_cache_qt` — the row's own kind @@ -1274,6 +1281,9 @@ fn map_recent_track(t: crate::recently_qt::RecentTrack) -> HomeCard { id: t.id, title: t.title, artist: t.subtitle, + // The one line this whole fix turns on: the history stores the origin + // and the card used to drop it on the floor via `..default()`. + source: t.source, art_url: if t.artwork_url.is_empty() { t.album_artwork_url } else { diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs index 23da5f27d..80fa5ff74 100644 --- a/crates/qbz-qt/src/local_playback.rs +++ b/crates/qbz-qt/src/local_playback.rs @@ -302,12 +302,90 @@ pub async fn play_plex_track(runtime: &Runtime, rating_key: String, play_id: u64 log::error!("[qbz-qt] plex play: no Plex credentials configured"); return false; } + // TIMED, because the shape of this delay does not match its obvious + // explanation. The whole-file download below is a known POC gap, but the + // owner measured this path taking LONGER than a Qobuz track that streams + // over the internet — and 58 MB over a LAN cannot do that. So before any + // redesign, the log says which segment actually costs: the metadata round + // trip, the transfer, or the handoff to the player. It also prints the base + // URL, because `resolve_base_url` can hand back the plex.tv RELAY instead + // of the LAN address, and a relayed transfer is a round trip through Plex's + // servers — that alone would outweigh a local download and would explain + // being slower than remote Qobuz. + let t0 = std::time::Instant::now(); + let base = cfg.base_url.clone(); + let is_lan = crate::local_plex::is_local_address(&base); + + // PROGRESSIVE FIRST. `plex_resolve_part_url` stops at the URL and the + // shared feeder Range-streams the original bytes, so audio starts on the + // first chunk instead of after the whole FLAC is in RAM. The doc on + // `PlexPartLocation` states the intent outright — "~1s to first audio + // instead of buffering the whole FLAC into RAM first" — and it went unused + // here only because the feeder lived inside the Slint binary until it was + // moved to `qbz_player::remote_stream`. + // + // Duration comes from the queue row (the feeder needs it for its buffer + // maths); 0 is acceptable — it only makes the estimate conservative. + let duration = runtime + .core() + .current_track() + .await + .map(|t| t.duration_secs as u64) + .unwrap_or(0); + match qbz_plex::plex_resolve_part_url(base.clone(), cfg.token.clone(), rating_key.clone()).await + { + Ok(loc) => { + let resolved = t0.elapsed(); + match qbz_player::remote_stream::stream_remote_track_into_player( + &runtime.core().player(), + play_id, + duration, + 0, + &loc.part_url, + "PLEX", + ) + .await + { + Ok(()) => { + log::info!( + "[qbz-qt][perf] plex play {play_id}: STREAMED — resolve {resolved:?}, \ + first audio {:?} — base {} ({})", + t0.elapsed(), + base, + if is_lan { "LAN" } else { "NOT a LAN address — relayed?" }, + ); + return true; + } + // The whole-file path below stays as the fallback, exactly as + // the reference keeps it: a server that refuses Range, or a + // part that will not probe, still plays — just slowly. + Err(e) => log::warn!( + "[qbz-qt] plex play {play_id}: streaming failed ({e}) — \ + falling back to whole-file download" + ), + } + } + Err(e) => log::warn!("[qbz-qt] plex play: part-url resolve failed ({e}) — full download"), + } match qbz_plex::plex_resolve_track_media(cfg.base_url, cfg.token, rating_key.clone()).await { Ok(media) => { + let fetched = t0.elapsed(); + let bytes = media.bytes.len(); + let t1 = std::time::Instant::now(); if let Err(e) = runtime.core().player().play_data(media.bytes, play_id) { log::error!("[qbz-qt] plex play: play_data {play_id} failed: {e}"); return false; } + log::info!( + "[qbz-qt][perf] plex play {play_id}: resolve+fetch {:?} for {} bytes \ + ({:.1} MB/s), play_data {:?} — base {} ({})", + fetched, + bytes, + (bytes as f64 / 1_048_576.0) / fetched.as_secs_f64().max(0.001), + t1.elapsed(), + base, + if is_lan { "LAN" } else { "NOT a LAN address — relayed?" }, + ); true } Err(e) => { @@ -685,3 +763,77 @@ mod tests { assert_eq!(plex_rating_key(None, 771), "771"); } } + +// --------------------------------------------------------------------------- +// Source-aware single-track play (Home's Recently-Played rail, 2026-08-13) +// --------------------------------------------------------------------------- + +/// Play ONE non-Qobuz track by its queue id, resolving it through its own +/// source. Returns false when the row cannot be resolved. +/// +/// WHY THIS EXISTS. `playback_qt::queue_track_for` has exactly two arms — the +/// Qobuz favourites feed, then `get_track` against the catalog — so any id that +/// misses the feed goes to the Qobuz API. For a local or Plex id that is a +/// guaranteed 404 (reported by the owner from Discover's Recently-Played rail: +/// `get_track(1099511673001)` on a `PLEX_TRACK_ID_FLOOR`-namespaced id). The +/// cortinilla has routed by source since it was written and says why in its own +/// comment: handing a local id to the catalog "would 404 or, worse, open +/// someone else's album that happens to share the number". This is that seam +/// for the rails that reach playback through the generic by-id entry. +/// +/// ROUTING IS BY THE ROW'S `source`, NEVER BY ID ARITHMETIC. `id >= +/// PLEX_TRACK_ID_FLOOR` is only unambiguous for Plex; plain local ids are small +/// `library.db` rowids that CAN collide with real Qobuz track ids, so a +/// speculative `get_track()` on the local DB could play the wrong file. It is +/// also what keeps Jellyfin/Navidrome a match arm here instead of another sweep +/// through the tree. +/// +/// An unresolvable row plays NOTHING and says so. Falling back to the Qobuz +/// path would reproduce the 404 this exists to remove, and falling back to +/// "some other track" is the failure the cortinilla explicitly refuses. +pub async fn play_single_from_source(runtime: &Runtime, track_id: u64, source: &str) -> bool { + let track = match source { + // A library row: the DB has everything, and `local_queue_track` builds + // the QueueTrack the audible step expects. + "local" | "qobuz_download" => { + tokio::task::spawn_blocking(move || with_db(|db| db.get_track(track_id as i64)).flatten()) + .await + .ok() + .flatten() + } + // A Plex row is SYNTHETIC — `local_plex::map_cached_to_local_track` + // mints it and it never touches library.db, so there is no row to read. + // The cache is queried by ALBUM, and the recently-played entry carries + // the album artist/title, which is exactly the key. Resolving through + // the real cached row is what recovers `rating_key` (the audible step + // needs it as `source_item_id_hint`, and it is NOT derivable from the + // namespaced id: the id packs the cache ROWID, the hint is a different + // column). + "plex" => { + let entry = crate::recently_qt::find_track(&track_id.to_string()); + let Some(entry) = entry else { + log::warn!("[qbz-qt] plex play: {track_id} is not in the recently-played store"); + return false; + }; + let key = crate::local_plex::album_key_for(&entry.album_artist, &entry.album_title); + tokio::task::spawn_blocking(move || { + crate::local_plex::album_tracks(&key) + .into_iter() + .find(|t| t.id as u64 == track_id) + }) + .await + .ok() + .flatten() + } + other => { + log::warn!("[qbz-qt] play: unknown source {other:?} for track {track_id}"); + return false; + } + }; + let Some(track) = track else { + log::warn!("[qbz-qt] play: {source} track {track_id} could not be resolved"); + return false; + }; + play_rows(runtime, vec![track], 0, false).await; + true +} diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 63f7b9af4..bc75a26b2 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -353,6 +353,52 @@ static SIDEBAR_LOADED: Mutex = Mutex::new(false); /// (`on_session_entered`) and again on logout, so neither a logout -> login nor /// an offline -> login transition can leave a new session reading the previous /// one's answer. Each latch's own doc explains what it gates. +/// Restore the persisted queue + current track, once per process. +/// +/// PAUSED by design (the shared module's "Phase A"): the queue and the cursor +/// come back, the audio does not start itself. The saved position rides along +/// and is consumed by the first play of that same track +/// (`session_persist::take_resume_for`, threaded through +/// `play_resolved_offline_aware`). +fn restore_session_once() { + static DONE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if DONE.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + // Bind the exit context here rather than at startup: this is the first + // moment a runtime exists AND a user session owns a store, so a snapshot + // taken on quit can never belong to nobody. `OnceLock` — later entries are + // ignored, which is what we want (the runtime is process-global anyway). + qbz_app::session_persist::bind_exit_ctx( + app(), + TOKIO.get().expect("tokio runtime set before the shell mounts").handle().clone(), + ); + // Crash-chain rung 3: two consecutive boots died even after the view reset, + // so skip the queue restore for THIS boot only. The persisted queue stays + // on disk untouched — a healthy boot brings it back. + if nav_qt::crash_level() >= 3 { + log::warn!("[crash-chain] session restore bypassed this boot (queue kept on disk)"); + return; + } + spawn(async move { + let runtime = app(); + if !qbz_app::session_persist::restore(&runtime).await { + return; + } + // The queue exists in the core now; the bar and the queue panel repaint + // from explicit refreshes, not from the core mutation. + playback_qt::refresh_now_playing(&runtime).await; + // AFTER the meta publish, never before: `refresh_now_playing` goes + // through `set_track`, which arms loading+playing for the real play + // path. Nothing was dispatched here, so those have to be taken back + // down or the app opens with the play button spinning. + now_playing::mark_restored_idle(); + playback_qt::publish_queue(&runtime).await; + let resume = qbz_app::session_persist::pending_resume_position(); + log::info!("[qbz-qt] session restored (resume position {resume}s)"); + }); +} + fn reset_session_latches() { *HOME_LOADED.lock().unwrap() = false; *LIBRARY_LOADED.lock().unwrap() = false; @@ -479,7 +525,7 @@ fn on_session_entered() { reset_session_latches(); // Phase 2: the shell mounts on the (only) "home" view; seed the nav // history and push the current now-playing model onto the bar. - nav_qt::record("home"); + nav_qt::record(&nav_qt::shell_entry_view()); now_playing::publish_current(); // Phase 3: fetch Discover > Home (online sessions only — the offline // engine gates Qobuz calls anyway, and the view shows the offline @@ -487,6 +533,12 @@ fn on_session_entered() { load_home_once(); // Phase 4: the 1 Hz playback state pump (idempotent). playback_qt::start_poll_loop(app()); + // Phase 4b: session restore (queue + current track, PAUSED). One-shot per + // process — `on_session_entered` is multi-entry (login, session restore, + // "Start offline") and restoring again on a re-login would clobber whatever + // the user has queued since. Runs AFTER the poll loop so the track-change + // edge is already listening when the restored cursor lands. + restore_session_once(); // Integrations runtime: applies the persisted MusicBrainz / Discord / // ListenBrainz opt-ins and starts the scrobble-queue flush watcher. // Strictly opt-in — every one of them is inert until the user connects it. @@ -1342,6 +1394,7 @@ pub(crate) fn enqueue_artist_top() { /// Track-row click (Library tracks): one-track queue through the core. pub(crate) fn play_track(track_id: u64) { search_qt::record_page_interaction("track", &track_id.to_string(), search_qt::InteractionAction::Play); + now_playing::begin_loading(); let runtime = app(); spawn(async move { if let Err(e) = playback_qt::play_single_track(&runtime, track_id).await { @@ -1350,6 +1403,34 @@ pub(crate) fn play_track(track_id: u64) { }); } +/// `play_track` with the row's ORIGIN attached — the single router for rails +/// that can show non-Qobuz tracks (Home's Recently-Played, and any future +/// mixed feed). Qobuz keeps the existing path byte for byte; everything else +/// resolves through its own source and never touches the catalog. +pub(crate) fn play_track_from(track_id: u64, source: String) { + if source.is_empty() || source == "qobuz" { + play_track(track_id); + return; + } + search_qt::record_page_interaction( + "track", + &track_id.to_string(), + search_qt::InteractionAction::Play, + ); + // Spinner ON at DISPATCH: everything after this is async, and a local file + // read or a Plex part fetch can take seconds with nothing on screen. + now_playing::begin_loading(); + let runtime = app(); + spawn(async move { + // No Qobuz fallback on failure, deliberately: that is the 404 this + // router exists to remove, and guessing another row is the failure the + // cortinilla refuses by design (search_qt.rs:1172). + if !local_playback::play_single_from_source(&runtime, track_id, &source).await { + log::error!("[qbz-qt] play_track_from: {source} track {track_id} did not play"); + } + }); +} + // ============================ Playlist view + DnD (phase 17) ============== /// Open the playlist detail view (sidebar row / playlist card click). @@ -2372,6 +2453,10 @@ pub(crate) fn reload_home() { + sections.for_you.iter().map(|s| s.items.len()).sum::(); publish_home_sections(§ions); + // LIVENESS. The shell has a mounted view with real content, so + // whatever was restored this boot did not kill it — clear the + // crash chain for the next start. + nav_qt::mark_startup_healthy(); log::info!( "[qbz-qt] home published: {}+{}+{} sections, {} cards, {} artwork misses", sections.home.len(), @@ -2574,6 +2659,14 @@ pub(crate) fn arm_hard_exit_watchdog(source: &'static str) { fn main() { qbz_log::install("info"); + // BEFORE any restore reads a pref: read + increment the crash chain, so a + // boot that dies from a restored view or queue degrades the NEXT boot + // instead of trapping the user in a loop it cannot escape from inside the + // UI. Cleared by `mark_startup_healthy` once the shell is usable. + nav_qt::arm_startup_probe(); + // Hand the level to the shared persistence module: the queue restore has + // its own rung on the same ladder (>=3 skips it for this boot only). + qbz_app::session_persist::set_crash_chain_level(nav_qt::crash_level()); // glibc resolver hardening (2026-08-10): this host's path to its first // resolver drops the SECOND of two parallel DNS queries on one socket, // so every getaddrinfo (which fires A+AAAA in parallel) stalls for the @@ -2653,6 +2746,19 @@ fn main() { // the watchdog `_exit(0)`s the process. Idempotent — the quit paths // arm it earlier, at the moment quit was requested. arm_hard_exit_watchdog("event-loop exit"); + // Final full snapshot. Placed here — after the loop and AFTER the + // watchdog — rather than in each QML quit handler: window close, tray + // "Quit" and the hotkey all converge on this line, so one call covers + // paths that four scattered ones would keep missing. (The reference + // flushes from the handlers instead — `tray/mod.rs:300`.) + // + // ORDER IS LOAD-BEARING: this is a synchronous `block_on` of a SQLite + // write on the main thread with no UI left. Behind the watchdog it can + // wedge and the process still dies in 5s; in front of it, a wedge is an + // app with no window that only `kill -9` ends — the 2026-08-04 quit + // incident exactly. No-op unless `persist_session` is on. + qbz_app::session_persist::save_on_exit(); + // Same reason as logout: leaving the app must stop the renderer. // // BOUNDED. This is a `block_on` on the main thread after the UI is diff --git a/crates/qbz-qt/src/nav_qt.rs b/crates/qbz-qt/src/nav_qt.rs index b54aa09e3..ad15ae9cb 100644 --- a/crates/qbz-qt/src/nav_qt.rs +++ b/crates/qbz-qt/src/nav_qt.rs @@ -43,14 +43,172 @@ static HISTORY: Mutex> = Mutex::new(None); fn with_history(f: impl FnOnce(&mut NavHistory) -> R) -> R { let mut guard = HISTORY.lock().unwrap(); let history = guard.get_or_insert_with(|| NavHistory { - entries: vec!["home".to_string()], + // Seeded with the STARTUP view, not a literal "home": booting on a + // restored view with a history rooted at Home would offer a Back to a + // page the user never opened this session. + entries: vec![startup_view()], index: 0, }); f(history) } /// Push a view as the new current entry (no-op when it IS the current one). +/// The top-level views it is SAFE to reopen the app on: no required id, so a +/// restore can never land on a detail page whose entity is gone. +/// +/// 1:1 with the reference's `last_view` contract (`ui_prefs.rs:489-492`), which +/// spells the set out and adds "Detail views (album/artist/playlist/…) are never +/// stored". Search and Settings are excluded on the same reasoning the reference +/// gives for `last_nav`: one is transient, the other is config — reopening the +/// app inside either is not "where you left off", it is a surprise. +/// `last_view` is written into the SHARED `ui_prefs.json` — the same file the +/// Slint build reads — so the value stored has to be the REFERENCE's +/// vocabulary, not this port's route ids. Four of the six differ: +/// +/// | reference (`ui_prefs.rs:489`) | this port's route | +/// |---|---| +/// | `home` | `home` | +/// | `favorites` | `library` | +/// | `local-library` | `local` | +/// | `mixtapes` | `mixtapes` | +/// | `collections` | `collections` | +/// | `discover` | (no twin — the reference splits Discover from Home) | +/// +/// Writing `local` / `library` verbatim would have looked correct here and +/// silently broken the Slint build's restore for anyone who runs both — the +/// owner's own prefs today read `last_view = "local-library"`, written by Slint. +/// Unknown values in either direction resolve to Home rather than guessing. +/// +/// Detail views (album/artist/playlist/…) are never stored: they need an id and +/// a restore could land on an entity that is gone. Search and Settings are +/// excluded too — transient and config respectively, and reopening the app +/// inside either is a surprise, not "where you left off". +const VIEW_TO_PREF: &[(&str, &str)] = &[ + ("home", "home"), + ("library", "favorites"), + ("local", "local-library"), + ("mixtapes", "mixtapes"), + ("collections", "collections"), +]; + +fn pref_for_view(view: &str) -> Option<&'static str> { + VIEW_TO_PREF.iter().find(|(v, _)| *v == view).map(|(_, p)| *p) +} + +fn view_for_pref(pref: &str) -> Option<&'static str> { + VIEW_TO_PREF + .iter() + .find(|(_, p)| *p == pref) + .map(|(v, _)| *v) + // The reference's `discover` has no separate route here: this port's + // Discover IS `home`. Accept it on READ so a prefs file written by the + // Slint build restores somewhere sensible instead of falling to Home. + .or(if pref == "discover" { Some("home") } else { None }) +} + +/// Crash-chain guard for the restore paths. +/// +/// A restore that crashes is a TRAP: the app dies on boot, and every retry +/// restores the same thing and dies again, with no way out from inside the UI. +/// The reference guards it with a probe file and a recovery ladder +/// (`main.rs:7218-7240`), and it is worth carrying because the failure mode is +/// "the app no longer starts", not "a page looks wrong". +/// +/// The ladder, and it never DELETES state — it bypasses it: +/// - level 1 (previous boot reached liveness): restore everything. +/// - level 2 (one prior boot died first): ignore the remembered view, open Home. +/// - level >= 3: additionally skip the persisted queue restore for this boot. +/// The queue file itself is left untouched. +fn probe_path() -> Option { + dirs::data_dir().map(|d| d.join("qbz").join("startup_probe_qt")) +} + +static CRASH_LEVEL: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); + +/// Read + increment the probe. Call ONCE, early in boot, before any restore. +pub fn arm_startup_probe() { + let Some(path) = probe_path() else { return }; + let prev: u8 = std::fs::read_to_string(&path) + .ok() + .and_then(|t| t.trim().parse().ok()) + .unwrap_or(0); + let level = prev.saturating_add(1); + CRASH_LEVEL.store(level, std::sync::atomic::Ordering::Relaxed); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&path, level.to_string()); + if level > 1 { + log::warn!( + "[qbz-qt] startup probe: level {level} — {} prior boot(s) died before liveness; \ + restore is degraded", + level - 1 + ); + } +} + +/// The app reached a state a user could act on. Clears the chain so the NEXT +/// boot restores normally. +pub fn mark_startup_healthy() { + if let Some(path) = probe_path() { + let _ = std::fs::write(&path, "0"); + } +} + +/// This boot's chain level (1 = the previous shutdown was clean). +pub fn crash_level() -> u8 { + CRASH_LEVEL.load(std::sync::atomic::Ordering::Relaxed).max(1) +} + +/// The view to open on: `startup_page` = "home" always Home, "remember" the last +/// safe top-level view. Unknown keys fall back to Home rather than guessing. +/// +/// The pref row for this has existed in Settings since the settings port and +/// NOTHING read it — the user could pick "Where you left off" and the app opened +/// on Home every time. That is the "renders, persists, drives nothing" defect +/// class this port has a name for. +pub fn startup_view() -> String { + if crate::settings_qt::pref_str("startup_page", "home") != "remember" { + return "home".to_string(); + } + // Ladder level 2+: a prior boot died before liveness, so the remembered + // view is a suspect. Open Home instead of retrying it. + if crash_level() >= 2 { + log::warn!("[qbz-qt] startup: remembered view bypassed (crash chain)"); + return "home".to_string(); + } + let last = crate::settings_qt::pref_str("last_view", "home"); + let resolved = view_for_pref(&last).unwrap_or("home"); + log::info!("[qbz-qt] startup: remember -> last_view {last:?} resolves to view {resolved:?}"); + resolved.to_string() +} + +/// The view the SHELL should mount on at this session entry. +/// +/// `init_shell_for_user` is MULTI-ENTRY (login, session restore, "Start +/// offline"), and it used to `record("home")` unconditionally. That is a +/// literal that outranks the whole startup-page pref: it pushed `home` over the +/// seeded startup entry AND — since `record` persists — rewrote `last_view` to +/// `home` on every boot, so "Where you left off" could never survive its own +/// startup. The restore is a ONE-SHOT: the first entry of the process honours +/// the pref, a later re-entry (re-login inside the same run) is a fresh session +/// and lands on Home the way the reference does. +pub fn shell_entry_view() -> String { + static USED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if USED.swap(true, std::sync::atomic::Ordering::SeqCst) { + return "home".to_string(); + } + startup_view() +} + pub fn record(view: &str) { + // Persist BEFORE the history mutation so the write reflects the view the + // user actually reached, and only for the safe set — a detail view leaves + // the stored value on the last safe root, which is exactly what the + // reference stores. + if let Some(pref) = pref_for_view(view) { + crate::settings_qt::save_pref("last_view", serde_json::json!(pref)); + } let (can_back, can_forward, current) = with_history(|h| { if h.entries[h.index] != view { h.entries.truncate(h.index + 1); diff --git a/crates/qbz-qt/src/now_playing.rs b/crates/qbz-qt/src/now_playing.rs index f6e4fefa9..00885cdfb 100644 --- a/crates/qbz-qt/src/now_playing.rs +++ b/crates/qbz-qt/src/now_playing.rs @@ -478,9 +478,39 @@ pub fn set_artwork_path(path: String) { /// branch normally clears the spinner via `set_position(has_audio = true)`, /// but a refused play never primes the peer, so a paused+idle peer would /// leave the spinner latched. +/// Light the spinner the MOMENT a play is dispatched, before anything is +/// resolved. +/// +/// `loading` used to be set only inside the meta publish, i.e. once the track +/// was already known — which leaves the entire resolve/fetch window with no +/// affordance anywhere in the shell. That window is seconds for a Plex part or +/// a cold CMAF session, and the owner read it as a dead click. The clear side +/// already exists (`clear_loading`, plus the position push once audio flows), +/// so this only moves the START earlier. +pub(crate) fn begin_loading() { + mutate(|m| m.loading = true); +} + pub(crate) fn clear_loading() { mutate(|m| m.loading = false); } + +/// A track's meta is on the bar but NOTHING was dispatched — the session +/// restore at startup. +/// +/// `set_track` asserts `loading = true` and `playing = true` because its normal +/// caller is a real play, and both are cleared by the audio that follows: +/// `set_position(has_audio = true)` turns the spinner off. A restored track has +/// no dispatch behind it and no audio ahead of it, so nothing ever arrives to +/// clear either flag — the app opened with the play button spinning and the +/// transport claiming to play, forever. Restore is the one caller that has to +/// say so explicitly. +pub(crate) fn mark_restored_idle() { + mutate(|m| { + m.loading = false; + m.playing = false; + }); +} pub fn set_position( elapsed_secs: i32, duration_secs: i32, diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 4b294b5d5..313998684 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -427,6 +427,14 @@ pub(crate) async fn play_resolved_offline_aware( ) -> Result<(), String> { let off = crate::offline_qt::get().await; let sink = off.as_ref().map(|_| crate::offline_cache_qt::row_sink()); + // Session resume: if this is the track restored at launch, start it at the + // saved position (consumed once). Only when the caller did not ask for a + // position itself — an explicit seek-then-play must win over the restore. + let start_position_secs = if start_position_secs == 0 { + qbz_app::session_persist::take_resume_for(track_id) + } else { + start_position_secs + }; runtime .core() .play_track_resolved( @@ -1423,13 +1431,21 @@ pub async fn toggle_play(runtime: &Arc>) { } } let event = runtime.core().player().get_playback_event(); - let result = if event.is_playing { + let was_playing = event.is_playing; + let result = if was_playing { runtime.core().pause() } else { runtime.core().resume() }; if let Err(e) = result { log::warn!("[qbz-qt] toggle-play failed: {e}"); + return; + } + if was_playing { + // Persist the paused position: pausing is the strongest signal the user + // is done for now, and the ~5s poll flush can be up to 5s stale. + // No-op unless `persist_session` is on. + qbz_app::session_persist::capture_and_save(runtime).await; } } @@ -1965,6 +1981,8 @@ pub fn start_poll_loop(runtime: Arc>) { let mut last_track_id: u64 = 0; let mut was_playing = false; let mut seen_position: u64 = 0; + // Divider for the ~5s position flush (see the save_position call). + let mut save_pos_tick: u64 = 0; // Track id we have already fired a gapless prefetch for, so this poll // does not re-request it on every tick while the download is in flight // (playback.rs:5049-5051). @@ -2235,6 +2253,14 @@ pub fn start_poll_loop(runtime: Arc>) { crate::recently_qt::record_queue_track(&track); } } + // Persist the session (queue + current track + position) so a + // restart can restore it. Hooked on the DE-DUPED track edge and + // not at each play call site: `play_queue_track` alone has five + // early returns (cast, peer, local, plex, Qobuz) and the album / + // single / shuffle entries add more, so a per-call-site hook is + // a list that silently goes stale. This edge sees them all. + // No-op unless `persist_session` is on. + qbz_app::session_persist::capture_and_save(&runtime).await; last_track_id = track_id; // The engine may have reached this track through a GAPLESS // hand-off, in which case the arming guard still names the @@ -2511,6 +2537,14 @@ pub fn start_poll_loop(runtime: Arc>) { } seen_position = position; + // Persist the live position every ~5s while playing so a crash keeps + // a near-current resume point. The reference ticks at 450ms and uses + // `% 11`; this loop ticks at 1 Hz, so the WALL-CLOCK equivalent is + // `% 5` — tick-count throttles do not translate 1:1. + save_pos_tick = save_pos_tick.wrapping_add(1); + if is_playing && track_id != 0 && save_pos_tick % 5 == 0 { + qbz_app::session_persist::save_position(position); + } // Reflect play/pause into the tray tooltip on the TRANSITION only, // so the "Middle-click to pause/play" hint stays correct without // spamming the updater channel every tick (§7-M12). The second of diff --git a/crates/qbz-qt/src/player_bridge.rs b/crates/qbz-qt/src/player_bridge.rs index cf47d0e85..dfabd911d 100644 --- a/crates/qbz-qt/src/player_bridge.rs +++ b/crates/qbz-qt/src/player_bridge.rs @@ -200,6 +200,12 @@ pub mod qbz_player { /// Track-row click (Library): play the track as a 1-element queue. #[qinvokable] fn play_track(self: Pin<&mut QbzPlayer>, track_id: QString); + /// Play one track KNOWING where the row came from ("qobuz" | "local" | + /// "plex"; empty = qobuz). Use this from any rail whose rows can be + /// non-Qobuz — `play_track` alone only has an id, and an id is not + /// enough to tell a library rowid from a Qobuz track id. + #[qinvokable] + fn play_track_from(self: Pin<&mut QbzPlayer>, track_id: QString, source: QString); /// Library track context menus: Play next ("next") / Play later /// ("later") / Add to queue ("queue") on a single feed track. #[qinvokable] @@ -450,6 +456,12 @@ impl qbz_player::QbzPlayer { } } + pub fn play_track_from(self: Pin<&mut Self>, track_id: QString, source: QString) { + if let Ok(id) = track_id.to_string().parse::() { + crate::play_track_from(id, source.to_string()); + } + } + pub fn enqueue_track(self: Pin<&mut Self>, track_id: QString, mode: QString) { if let Ok(id) = track_id.to_string().parse::() { crate::enqueue_track(id, mode.to_string()); diff --git a/crates/qbz-qt/src/recently_qt.rs b/crates/qbz-qt/src/recently_qt.rs index 3aacdfb2e..8e6e2f82f 100644 --- a/crates/qbz-qt/src/recently_qt.rs +++ b/crates/qbz-qt/src/recently_qt.rs @@ -61,6 +61,14 @@ pub struct RecentTrack { pub source: String, } +/// One history entry by track id. The store is a short JSON list (24 rows +/// reach the rail), so a linear scan is cheaper than any index — and this is +/// the ONLY place that knows a non-Qobuz row's album key, which is what the +/// Plex cache is queried by. +pub fn find_track(id: &str) -> Option { + load_tracks().into_iter().find(|t| t.id == id) +} + /// One recently-played album (its own history since #567). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct RecentAlbum { diff --git a/crates/qbz-qt/src/search_qt.rs b/crates/qbz-qt/src/search_qt.rs index 07a346349..12ae46685 100644 --- a/crates/qbz-qt/src/search_qt.rs +++ b/crates/qbz-qt/src/search_qt.rs @@ -1032,15 +1032,41 @@ fn flat_index_content_y(data: &CortinillaData, flat_index: i32) -> f64 { /// ids are in a different space from the catalog's, and a local artist row /// has no id at all, so a bucket keyed on them could never be matched again. pub fn row_clicked(flat_index: i32) { + // TRACED, because this router had THREE ways to do nothing without saying + // so — no snapshot, no matching flat index, and an unhandled kind — and the + // owner reported a local row whose click produced no effect AND no log + // line. A silent no-op is indistinguishable from a dead signal from the + // outside; whichever of these fires, the log now names it. let snap = LAST_CORT.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let Some(data) = snap else { return }; + let Some(data) = snap else { + log::warn!("[qbz-qt] cortinilla click {flat_index}: NO SNAPSHOT (LAST_CORT is None)"); + return; + }; let row = data .top .iter() .chain(data.sections.iter().flat_map(|s| s.rows.iter())) .find(|r| r.flat_index == flat_index) .cloned(); - let Some(row) = row else { return }; + let Some(row) = row else { + let have: Vec = data + .top + .iter() + .chain(data.sections.iter().flat_map(|s| s.rows.iter())) + .map(|r| r.flat_index) + .collect(); + log::warn!( + "[qbz-qt] cortinilla click {flat_index}: NO ROW with that flat index — \ + snapshot holds {have:?}" + ); + return; + }; + log::info!( + "[qbz-qt] cortinilla click {flat_index}: kind={} source={} id={}", + row.kind, + row.source, + row.id + ); set_cortinilla_open(false); if row.source != "local" { let action = if row.kind == "track" { @@ -1057,12 +1083,31 @@ pub fn row_clicked(flat_index: i32) { // album that happens to share the number. if row.source == "local" { match row.kind.as_str() { - // The group key opens the local album view directly. - "album" => crate::local_bridge::open_album_by_id(row.id), - // BY NAME — local artists have no id (search_local.rs). - "artist" => crate::local_album_actions::open_artist_by_name(row.title), + // Through the SHARED album router, not straight to the loader. + // `open_album_by_id` only publishes the document — it does not + // navigate — so calling it alone loaded the album into a view that + // was never shown: the click did nothing and logged nothing + // (owner report, 2026-08-13, on `plex:4868...`). `crate::open_album` + // is the one place that pairs the load with `nav_qt::record` + + // `set_current_view`, and its `is_local_feed_id` arm already + // recognises both `plex:` keys and path-shaped local group keys. + "album" => crate::open_album(row.id), + // BY NAME — local artists have no id (search_local.rs). Same shape + // as the album row: `open_artist_by_name` only parks the name in + // `local_pending_artist` for the Artists tab to consume, so the + // view has to be brought up for anything to consume it. + "artist" => { + crate::nav_qt::record("local"); + crate::shell_bridge::ui(|mut b| { + b.as_mut() + .set_current_view(QString::from("local")) + }); + crate::local_album_actions::open_artist_by_name(row.title); + } "track" => play_local_row(&row.id), - _ => {} + other => log::warn!( + "[qbz-qt] cortinilla click: local row kind {other:?} has no route — nothing done" + ), } return; } diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 5beb0c27d..bf0adb3d1 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1685,6 +1685,12 @@ fn backend_label(t: AudioBackendType) -> String { pub async fn publish_snapshot() { let audio_settings = audio_settings(); let prefs = with_playback(|s| s.get_preferences()).unwrap_or_default(); + // Re-seed the persistence gates from what we just read. `init_for_user` + // seeds them at login and the two toggles push them live, so this only + // matters when the prefs changed OUTSIDE this process — the Slint build + // writes the same per-user DB. Cheap, and it makes the gates self-healing + // instead of trusting that nothing else ever touches the file. + qbz_app::session_persist::set_gates(prefs.persist_session, prefs.resume_playback_position); let streaming_key = streaming_quality(); // The now-playing stamp's two output LEDs are a pure function of the @@ -2062,10 +2068,17 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, r } "persist-session" => { - with_playback(|s| s.set_persist_session(value)).map(|_| Apply::None) + // Push the LIVE gate too, not just the stored bool: capture/restore + // read the gate, so persisting alone left the toggle inert until a + // restart — the "renders, persists, drives nothing" class again. + let r = with_playback(|s| s.set_persist_session(value)).map(|_| Apply::None); + qbz_app::session_persist::set_persist_gate(value); + r } "resume-position" => { - with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None) + let r = with_playback(|s| s.set_resume_playback_position(value)).map(|_| Apply::None); + qbz_app::session_persist::set_resume_gate(value); + r } // --- Appearance (phase 19): plain ui_prefs bools (+ live bridge // side-effects where the POC already has the consumer). diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 22333c9e7..5bd4da966 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -721,7 +721,13 @@ impl Default for QbzShellRust { nav_header_compact: crate::settings_qt::nav_header_compact(), sidebar_playlist_collage: crate::settings_qt::sidebar_playlist_collage(), queue_open: false, - current_view: QString::from("home"), + // The persisted startup page (Settings > Appearance > Startup + // page). Resolved at CONSTRUCTION so the first mounted view is + // already the right one — routing after the fact would flash Home + // and then swap, which is worse than not restoring at all. + // `startup_view()` returns "home" unless the pref says "remember" + // AND the stored view is in the safe set. + current_view: QString::from(crate::nav_qt::startup_view().as_str()), can_back: false, nav_tab: QString::default(), nav_tab_view: QString::default(),