From b4caa5a3095a7f3f816e051b29706d3bfeb905d7 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:46:05 -0600 Subject: [PATCH] feat(slint): Settings Offline Mode group (tri-state status, induced toggle, network folders) New OFFLINE MODE group above the cache group on the Offline settings page: read-only tri-state status row (online / no connection / offline mode enabled, with captive-portal hint) plus an honest Check now that forces an actor re-probe; Enable Offline Mode toggle driving the shared engine (issue #279 stream_first_track snapshot/restore included, Playback panel re-synced after); Show Network Folder Content toggle mounted only while induced offline is on. Deliberate deviations from Tauri per the approved spec: no probe gate on disable, no dead cast/scrobbling toggles. cc --- crates/qbz-slint/src/main.rs | 19 ++++ crates/qbz-slint/src/offline_mode.rs | 53 +++++++++- crates/qbz-slint/src/settings.rs | 90 ++++++++++++++++ crates/qbz-slint/ui/app.slint | 2 +- .../ui/settings/OfflineSettings.slint | 100 ++++++++++++++++-- .../qbz-slint/ui/settings/SettingsView.slint | 6 +- crates/qbz-slint/ui/state.slint | 24 +++++ 7 files changed, 282 insertions(+), 12 deletions(-) diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 43139104e..0a91ffcc0 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -3990,6 +3990,25 @@ fn main() -> Result<(), Box> { }); } + // Settings > Offline MODE — re-seed the toggle states on panel mount + // (the panel's init fires load), and the status row's "Check now" + // connectivity re-probe. The toggles themselves persist through the + // generic settings-bool path above. + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_load(move || { + offline_mode::seed_settings(weak.clone(), handle.clone()); + }); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window.global::().on_check_now(move || { + offline_mode::check_now(weak.clone(), handle.clone()); + }); + } + // Appearance settings persistence. The toggles/selects set their // AppearanceState property locally, then fire these generic callbacks so // the choice survives restart. Tray keys persist to the shared per-user diff --git a/crates/qbz-slint/src/offline_mode.rs b/crates/qbz-slint/src/offline_mode.rs index 8193d94ff..188671fd9 100644 --- a/crates/qbz-slint/src/offline_mode.rs +++ b/crates/qbz-slint/src/offline_mode.rs @@ -22,7 +22,7 @@ use qbz_app::offline_mode::{Connectivity, ConnectivityActor, OfflineMode, Offlin use qbz_app::settings::subscription::SubscriptionStateStore; use qbz_app::user_data::UserDataPaths; -use crate::{AppWindow, OfflineState}; +use crate::{AppWindow, OfflineState, SettingsState}; /// Process-global engine. Exists from first use; per-user state binds via /// [`init_for_user`], connectivity via [`start`]. @@ -57,15 +57,58 @@ pub fn start() { } } -/// Force an immediate connectivity re-probe (Settings "Check now", -/// consumed by the Settings slice). -#[allow(dead_code)] +/// Force an immediate connectivity re-probe (Settings "Check now"). pub fn request_recheck() { if let Some(actor) = CONNECTIVITY.get() { actor.request_recheck(); } } +/// Settings > Offline "Check now": flag the in-flight state (the status +/// row's button flips to "Checking..."), then force an actor re-probe. +/// The flag clears on the next engine broadcast ([`apply_status`]) — or +/// after a short timeout when the verdict comes back unchanged, since the +/// actor only broadcasts state flips. +pub fn check_now(weak: slint::Weak, handle: tokio::runtime::Handle) { + let set_weak = weak.clone(); + let _ = set_weak.upgrade_in_event_loop(|w| { + w.global::().set_offline_checking(true); + }); + request_recheck(); + handle.spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_offline_checking(false); + }); + }); +} + +/// Seed the Settings > Offline MODE toggle states from the persisted +/// engine store. Fired by the panel's `init` (`OfflineModeActions.load`), +/// so every mount of Settings > Offline re-reads them — the same lazy-load +/// hook LocalLibrarySettings uses. Best-effort: pre-session reads (no +/// store bound) keep the defaults. +pub fn seed_settings(weak: slint::Weak, handle: tokio::runtime::Handle) { + handle.spawn(async move { + let settings = match tokio::task::spawn_blocking(|| engine().settings()).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + log::warn!("[qbz-slint] offline mode settings read failed: {e}"); + return; + } + Err(e) => { + log::error!("[qbz-slint] offline mode settings seed task failed: {e}"); + return; + } + }; + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + st.set_offline_mode_enabled(settings.manual_offline_mode); + st.set_offline_show_network_folders(settings.show_network_folders_in_manual_offline); + }); + }); +} + /// Mirror every engine status change into the `OfflineState` Slint global /// (login affordances + the D2 recovery banner read it). Also seeds /// `has-previous-session` once; `enter_shell` refreshes it after a @@ -107,6 +150,8 @@ fn apply_status(w: &AppWindow, status: OfflineStatus) { }); state.set_captive_portal(status.captive_portal); state.set_show_recovery_banner(status.show_recovery_banner()); + // A status broadcast resolves any in-flight Settings "Check now". + w.global::().set_offline_checking(false); } /// `/qbz/users//` — the per-user directory both the diff --git a/crates/qbz-slint/src/settings.rs b/crates/qbz-slint/src/settings.rs index 20a188239..9369430d2 100644 --- a/crates/qbz-slint/src/settings.rs +++ b/crates/qbz-slint/src/settings.rs @@ -754,6 +754,20 @@ pub async fn handle_bool( value: bool, ) { let key = key.as_str(); + // Offline-MODE toggles persist through the shared engine's per-user + // store, not the audio/playback stores — routed apart from the Apply + // machinery below. + match key { + "offline-mode-enabled" => { + set_offline_mode(ctx, runtime, weak, value).await; + return; + } + "offline-show-network-folders" => { + set_offline_network_folders(weak, value); + return; + } + _ => {} + } // Cross-setting cascades — force dependent settings off and persist // those forced changes. `cascaded` flags whether a full snapshot // re-push is needed afterwards. @@ -873,6 +887,82 @@ pub async fn handle_bool( } } +/// Settings > Offline — the Enable Offline Mode toggle (induced offline). +/// +/// The shared engine persists the flag, handles the #279 stream-first +/// snapshot/restore against the real audio store, and broadcasts the +/// status change (the offline_mode UI forwarder updates `OfflineState`). +/// Exit is ALWAYS allowed — no confirm dialog, no network-probe gate +/// (deliberate improvement over Tauri's exit trap, spec §3.1). +async fn set_offline_mode( + ctx: Arc, + runtime: Arc>, + weak: slint::Weak, + value: bool, +) { + let engine = crate::offline_mode::engine(); + // Best-effort #279: pass the real audio store when it's open (the + // outer Ok-wrap keeps store-unavailability distinct from engine + // failures); flip the mode without the snapshot when it isn't. + let result = match with_audio(&ctx.audio, |store| { + Ok(engine.set_induced(value, Some(store))) + }) { + Ok(inner) => inner, + Err(e) => { + log::warn!( + "[qbz-slint] offline toggle: audio store unavailable for the #279 snapshot ({e}); flipping without it" + ); + engine.set_induced(value, None) + } + }; + match result { + Ok(status) => { + log::info!( + "[qbz-slint] offline mode toggled: induced={value} (mode={:?})", + status.mode + ); + // The #279 snapshot may have mutated stream_first_track behind + // the Playback panel's back: reload the live player settings and + // re-push the full snapshot so the "Stream uncached tracks" + // toggle stays honest (Tauri's audio-settings-changed re-sync). + apply_audio(&ctx, &runtime, Apply::Reload); + rebuild_and_push(ctx, weak).await; + } + Err(e) => { + log::error!("[qbz-slint] offline mode toggle failed: {e}"); + // Revert the optimistic toggle to the engine's actual state. + let actual = engine + .settings() + .map(|s| s.manual_offline_mode) + .unwrap_or_else(|_| { + engine.status().mode == qbz_app::offline_mode::OfflineMode::InducedOffline + }); + let _ = weak.upgrade_in_event_loop(move |w| { + w.global::().set_offline_mode_enabled(actual); + }); + } + } +} + +/// Settings > Offline — the Show Network Folder Content policy toggle +/// (visible only while induced offline is on). Pure persistence; the +/// gating slice consumes the flag. +fn set_offline_network_folders(weak: slint::Weak, value: bool) { + let engine = crate::offline_mode::engine(); + if let Err(e) = engine.set_show_network_folders(value) { + log::error!("[qbz-slint] show-network-folders toggle failed: {e}"); + // Revert the optimistic toggle to the persisted state. + let actual = engine + .settings() + .map(|s| s.show_network_folders_in_manual_offline) + .unwrap_or(!value); + let _ = weak.upgrade_in_event_loop(move |w| { + w.global::() + .set_offline_show_network_folders(actual); + }); + } +} + /// Handle a slider change: persist it and reload the player settings. /// Currently only the Initial Buffer Size slider exists. pub fn handle_slider( diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 66c5a01d9..8170b6661 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint"; import { Toast } from "primitives/Toast.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, OfflineState } from "state.slint"; +export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, OfflineState, OfflineModeActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/settings/OfflineSettings.slint b/crates/qbz-slint/ui/settings/OfflineSettings.slint index 262593555..790802c77 100644 --- a/crates/qbz-slint/ui/settings/OfflineSettings.slint +++ b/crates/qbz-slint/ui/settings/OfflineSettings.slint @@ -1,13 +1,26 @@ -// Settings > Offline Playback (cache half). Same structure as the other -// settings panels (GroupHeader + SettingRow + a secondary button control) so -// the section stays consistent as more offline settings land. Launches the -// Offline Cache Manager + offers open-folder / clear-all. The Local-Library -// rows (show-in-library, repair) arrive with the Local Library port. +// Settings > Offline — two explicitly-titled groups (the Tauri tab hid +// this boundary; the owner endorsed the split): +// +// - OFFLINE MODE — the app operating without Qobuz: tri-state status row +// (online / no connection / induced) with an honest "Check now" +// re-probe, the persisted Enable Offline Mode toggle (#279 stream-first +// snapshot handled by the shared engine), and the network-folders +// policy toggle (mounted only while induced is on, Tauri parity). +// - OFFLINE CACHE — downloads: manager launch + open-folder / clear-all. +// The Local-Library rows (show-in-library, repair) arrive with the +// Local Library port. +// +// Same structure as the other settings panels (GroupHeader + SettingRow + +// SecondaryButton). Toggle changes bubble through settings-bool; the +// status row reads the live OfflineState global; the panel's init +// re-seeds the toggle states from the engine store (OfflineModeActions). import { Theme } from "../foundation/semantic-colors.slint"; +import { Palette } from "../foundation/palette.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { OfflineManagerActions } from "../state.slint"; +import { OfflineManagerActions, OfflineModeActions, OfflineState, SettingsState } from "../state.slint"; +import { QbzToggle } from "../primitives/QbzToggle.slint"; import { SettingRow } from "SettingRow.slint"; component GroupHeader inherits Text { @@ -17,6 +30,11 @@ component GroupHeader inherits Text { font-weight: Typography.semibold; } +component Divider inherits Rectangle { + height: 1px; + background: Theme.border-subtle; +} + // Matches AudioSettings/AppearanceSettings' SecondaryButton. component SecondaryButton inherits Rectangle { in property label; @@ -47,8 +65,78 @@ component SecondaryButton inherits Rectangle { } export component OfflineSettings inherits VerticalLayout { + callback settings-bool(string, bool); + spacing: 4px; + // Re-seed the toggle states from the engine store on every mount of + // this section (same lazy-load hook LocalLibrarySettings uses). + init => { + OfflineModeActions.load(); + } + + GroupHeader { text: @tr("OFFLINE MODE"); } + + SettingRow { + label: @tr("Status"); + description: OfflineState.captive-portal + ? @tr("Captive portal detected — sign in to the network to get online.") + : ""; + HorizontalLayout { + spacing: 16px; + VerticalLayout { + alignment: center; + // Tri-state: online green / real-offline amber / induced accent. + Text { + text: OfflineState.mode == 2 ? @tr("Offline mode enabled") + : OfflineState.mode == 1 ? @tr("No connection") + : @tr("Online"); + color: OfflineState.mode == 2 ? Theme.accent + : OfflineState.mode == 1 ? Palette.warning + : #22c55e; + font-size: Typography.body; + font-weight: Typography.medium; + } + } + SecondaryButton { + label: SettingsState.offline-checking ? @tr("Checking...") : @tr("Check now"); + clicked => { + if (!SettingsState.offline-checking) { + OfflineModeActions.check-now(); + } + } + } + } + } + SettingRow { + label: @tr("Enable Offline Mode"); + description: @tr("Manually switch to offline mode even with internet."); + QbzToggle { + checked: SettingsState.offline-mode-enabled; + toggled(v) => { + SettingsState.offline-mode-enabled = v; + root.settings-bool("offline-mode-enabled", v); + } + } + } + // The network-folders policy only applies while induced offline is on, + // so the row only mounts then (Tauri parity; ADR-010 conditional mount). + if SettingsState.offline-mode-enabled: SettingRow { + label: @tr("Show Network Folder Content"); + description: @tr("Display content from network-mounted folders (NAS, Samba, etc.) while in offline mode."); + QbzToggle { + checked: SettingsState.offline-show-network-folders; + toggled(v) => { + SettingsState.offline-show-network-folders = v; + root.settings-bool("offline-show-network-folders", v); + } + } + } + + Rectangle { height: 12px; } + Divider { } + Rectangle { height: 12px; } + GroupHeader { text: @tr("OFFLINE CACHE"); } SettingRow { diff --git a/crates/qbz-slint/ui/settings/SettingsView.slint b/crates/qbz-slint/ui/settings/SettingsView.slint index 629256a0b..da013afe1 100644 --- a/crates/qbz-slint/ui/settings/SettingsView.slint +++ b/crates/qbz-slint/ui/settings/SettingsView.slint @@ -219,7 +219,11 @@ export component SettingsView inherits Rectangle { // AppearanceState and emit its own callbacks, so no // SettingsView-level callback forwarding is needed here. if !SettingsState.loading && SettingsState.section == 2: AppearanceSettings { } - if !SettingsState.loading && SettingsState.section == 3: OfflineSettings { } + if !SettingsState.loading && SettingsState.section == 3: OfflineSettings { + settings-bool(k, v) => { + root.settings-bool(k, v); + } + } if !SettingsState.loading && SettingsState.section == 4: LocalLibrarySettings { } if !SettingsState.loading && SettingsState.section == 5: DeveloperSettings { } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index a91dbed56..33b1bbf6b 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -2040,6 +2040,20 @@ export global SettingsState { // Playback — "When quality retries fail" dropdown. in-out property <[string]> retry-behaviors: []; in-out property retry-behavior-index: 0; + + // --- OFFLINE MODE -------------------------------------------------------- + + // Settings > Offline, MODE group (the app operating without Qobuz — NOT + // the offline cache). Backed by the shared engine's per-user store, not + // the audio/playback stores: seeded by OfflineModeActions.load() on panel + // mount; persisted through settings-bool keys "offline-mode-enabled" / + // "offline-show-network-folders". + in-out property offline-mode-enabled: false; + in-out property offline-show-network-folders: false; + // "Check now" re-probe in flight — flips the status row's button label. + // Cleared by Rust on the next status broadcast, or after a short timeout + // when the verdict comes back unchanged (the actor only broadcasts flips). + in-out property offline-checking: false; } // Appearance settings — backs the Settings > Appearance panel. 1:1 with @@ -3121,3 +3135,13 @@ export global OfflineState { // successful shell entry. in property login-error: ""; } + +// Settings > Offline, MODE group — Rust-wired actions (crate::offline_mode). +export global OfflineModeActions { + // (Re)seed the MODE-group toggle states from the engine store. Fired by + // the panel's init, so every mount of Settings > Offline re-reads them. + callback load(); + // Force an immediate connectivity re-probe (the status row's "Check + // now"). The status text updates by itself via the engine forwarder. + callback check-now(); +}