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
This commit is contained in:
vicrodh committed 2026-06-09 20:46:05 -06:00
1 parent 38e0b4be27
commit b4caa5a309
7 files changed
+282 -12

No files matched your search

+19
View File
@@ -3990,6 +3990,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// 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::<OfflineModeActions>().on_load(move || {
offline_mode::seed_settings(weak.clone(), handle.clone());
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<OfflineModeActions>().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
+49 -4
View File
@@ -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<AppWindow>, handle: tokio::runtime::Handle) {
let set_weak = weak.clone();
let _ = set_weak.upgrade_in_event_loop(|w| {
w.global::<SettingsState>().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::<SettingsState>().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<AppWindow>, 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::<SettingsState>();
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::<SettingsState>().set_offline_checking(false);
}
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory both the
+90
View File
@@ -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<SettingsCtx>,
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
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::<SettingsState>().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<AppWindow>, 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::<SettingsState>()
.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(
+1 -1
View File
@@ -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`.
@@ -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 <string> 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 {
@@ -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 { }
}
+24
View File
@@ -2040,6 +2040,20 @@ export global SettingsState {
// Playback — "When quality retries fail" dropdown.
in-out property <[string]> retry-behaviors: [];
in-out property <int> 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 <bool> offline-mode-enabled: false;
in-out property <bool> 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 <bool> 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 <string> 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();
}