feat(slint): offline status UI plumbing, login affordances, recovery banner
New OfflineState global mirrored from the shared engine by a status forwarder task. Login screen: start-offline link gated on a previous session existing, connectivity-down callout pointing at it (with captive portal hint), init-error callout fed from the restore flow. Shell: D2 recovery banner (connection back during an unauthenticated offline session) with one-click token sign-in that lifts the offline gate for the login attempt and re-enters the full online shell. Double-init hardening for the recovery path re-running enter_shell over a live shell: synchronous guard on the Linux tray init (its OnceLock was set asynchronously, leaving a duplicate-tray window) and a start guard on the playback poll loop (a second loop would double-advance the queue). cc
This commit is contained in:
8 files changed
+275
-6
No files matched your search
@@ -217,6 +217,11 @@ async fn enter_shell(
|
|||||||
let state = w.global::<SessionState>();
|
let state = w.global::<SessionState>();
|
||||||
state.set_user_name(session.display_name.into());
|
state.set_user_name(session.display_name.into());
|
||||||
state.set_subscription(session.subscription.into());
|
state.set_subscription(session.subscription.into());
|
||||||
|
// A successful login means a previous session now exists; clear any
|
||||||
|
// stale boot restore error from the login screen.
|
||||||
|
let offline_state = w.global::<OfflineState>();
|
||||||
|
offline_state.set_has_previous_session(true);
|
||||||
|
offline_state.set_login_error("".into());
|
||||||
seed_tray_appearance(&w, &tray);
|
seed_tray_appearance(&w, &tray);
|
||||||
// Seed the My QBZ branding (label + icon) from the per-user store so
|
// Seed the My QBZ branding (label + icon) from the per-user store so
|
||||||
// the sidebar row + Settings row paint the custom values immediately.
|
// the sidebar row + Settings row paint the custom values immediately.
|
||||||
@@ -3446,6 +3451,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// lifetime (login screen included — the restore flow and the D2 recovery
|
// lifetime (login screen included — the restore flow and the D2 recovery
|
||||||
// banner both depend on it). Per-user state binds later on activation.
|
// banner both depend on it). Per-user state binds later on activation.
|
||||||
offline_mode::start();
|
offline_mode::start();
|
||||||
|
// Mirror engine status into the OfflineState Slint global (login
|
||||||
|
// affordances + the D2 recovery banner) and seed has-previous-session.
|
||||||
|
offline_mode::start_ui_forwarder(window.as_weak());
|
||||||
|
|
||||||
// Startup: initialize the core, then try to restore a saved session.
|
// Startup: initialize the core, then try to restore a saved session.
|
||||||
// A valid saved token jumps straight to the shell; otherwise the
|
// A valid saved token jumps straight to the shell; otherwise the
|
||||||
@@ -3473,7 +3481,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[qbz-slint] session restore failed: {e}");
|
log::error!("[qbz-slint] session restore failed: {e}");
|
||||||
let _ = weak.upgrade_in_event_loop(|w| w.set_screen(AppScreen::Login));
|
// Surface the failure on the login screen (init-error box,
|
||||||
|
// spec §4.1); cleared again on any successful shell entry.
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
w.global::<OfflineState>().set_login_error(e.into());
|
||||||
|
w.set_screen(AppScreen::Login);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3543,6 +3556,55 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D2 recovery: one click on the shell banner re-logs-in with the saved
|
||||||
|
// token and runs the full online entry over the live offline session.
|
||||||
|
{
|
||||||
|
let runtime = app_runtime.clone();
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
let image_cache = image_cache.clone();
|
||||||
|
let settings_ctx = settings_ctx.clone();
|
||||||
|
window.on_recovery_login(move || {
|
||||||
|
let runtime = runtime.clone();
|
||||||
|
let weak = weak.clone();
|
||||||
|
let image_cache = image_cache.clone();
|
||||||
|
let settings_ctx = settings_ctx.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
// The offline session keeps the Qobuz gate closed (D3), which
|
||||||
|
// would refuse the recovery login itself. Lift the session
|
||||||
|
// flag for the attempt; restore it on failure so the gate
|
||||||
|
// closes again and the banner returns (connectivity is up).
|
||||||
|
offline_mode::engine().set_offline_session(false);
|
||||||
|
match auth::restore_saved_session(&runtime).await {
|
||||||
|
Ok(Some(session)) => {
|
||||||
|
log::info!(
|
||||||
|
"[qbz-slint] recovery login succeeded for user {}",
|
||||||
|
session.user_id
|
||||||
|
);
|
||||||
|
enter_shell(runtime, weak, image_cache, settings_ctx, session).await;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::warn!("[qbz-slint] recovery login: saved session no longer usable");
|
||||||
|
offline_mode::engine().set_offline_session(true);
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
toast::error(&w, "Sign-in failed — the saved session is no longer valid");
|
||||||
|
w.global::<OfflineState>()
|
||||||
|
.set_login_error("Saved session is no longer valid".into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[qbz-slint] recovery login failed: {e}");
|
||||||
|
offline_mode::engine().set_offline_session(true);
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
toast::error(&w, format!("Sign-in failed: {e}"));
|
||||||
|
w.global::<OfflineState>().set_login_error(e.into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Open an album: record history, then load and show it.
|
// Open an album: record history, then load and show it.
|
||||||
{
|
{
|
||||||
let runtime = app_runtime.clone();
|
let runtime = app_runtime.clone();
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::{Arc, LazyLock, Mutex, OnceLock};
|
use std::sync::{Arc, LazyLock, Mutex, OnceLock};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use qbz_app::offline_mode::{ConnectivityActor, OfflineModeEngine};
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
|
use qbz_app::offline_mode::{Connectivity, ConnectivityActor, OfflineMode, OfflineModeEngine, OfflineStatus};
|
||||||
use qbz_app::settings::subscription::SubscriptionStateStore;
|
use qbz_app::settings::subscription::SubscriptionStateStore;
|
||||||
use qbz_app::user_data::UserDataPaths;
|
use qbz_app::user_data::UserDataPaths;
|
||||||
|
|
||||||
|
use crate::{AppWindow, OfflineState};
|
||||||
|
|
||||||
/// Process-global engine. Exists from first use; per-user state binds via
|
/// Process-global engine. Exists from first use; per-user state binds via
|
||||||
/// [`init_for_user`], connectivity via [`start`].
|
/// [`init_for_user`], connectivity via [`start`].
|
||||||
static ENGINE: LazyLock<Arc<OfflineModeEngine>> =
|
static ENGINE: LazyLock<Arc<OfflineModeEngine>> =
|
||||||
@@ -62,6 +66,49 @@ pub fn request_recheck() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// successful login. Spawned once from `main` right after [`start`]
|
||||||
|
/// (needs the tokio runtime context and a created window).
|
||||||
|
pub fn start_ui_forwarder(weak: slint::Weak<AppWindow>) {
|
||||||
|
let has_previous = UserDataPaths::load_last_user_id().is_some();
|
||||||
|
let seed_weak = weak.clone();
|
||||||
|
let _ = seed_weak.upgrade_in_event_loop(move |w| {
|
||||||
|
w.global::<OfflineState>()
|
||||||
|
.set_has_previous_session(has_previous);
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut rx = engine().subscribe();
|
||||||
|
loop {
|
||||||
|
let status = *rx.borrow_and_update();
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| apply_status(&w, status));
|
||||||
|
if rx.changed().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push one engine status snapshot into the Slint global (UI thread).
|
||||||
|
fn apply_status(w: &AppWindow, status: OfflineStatus) {
|
||||||
|
let state = w.global::<OfflineState>();
|
||||||
|
state.set_offline(status.is_offline());
|
||||||
|
state.set_mode(match status.mode {
|
||||||
|
OfflineMode::Online => 0,
|
||||||
|
OfflineMode::RealOffline => 1,
|
||||||
|
OfflineMode::InducedOffline => 2,
|
||||||
|
});
|
||||||
|
state.set_connectivity(match status.connectivity {
|
||||||
|
Connectivity::Unknown => 0,
|
||||||
|
Connectivity::Up => 1,
|
||||||
|
Connectivity::Down => 2,
|
||||||
|
});
|
||||||
|
state.set_captive_portal(status.captive_portal);
|
||||||
|
state.set_show_recovery_banner(status.show_recovery_banner());
|
||||||
|
}
|
||||||
|
|
||||||
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory both the
|
/// `<data_dir>/qbz/users/<user_id>/` — the per-user directory both the
|
||||||
/// engine store and the subscription store live in. Matches the Tauri
|
/// engine store and the subscription store live in. Matches the Tauri
|
||||||
/// per-user path (and `tray_settings::user_dir`).
|
/// per-user path (and `tray_settings::user_dir`).
|
||||||
|
|||||||
@@ -2595,11 +2595,20 @@ mod tests {
|
|||||||
/// Start the playback poll loop. Runs for the app lifetime: every ~450ms
|
/// Start the playback poll loop. Runs for the app lifetime: every ~450ms
|
||||||
/// it reads the player event and pushes position / progress onto
|
/// it reads the player event and pushes position / progress onto
|
||||||
/// `NowPlayingState`. When a track ends it auto-advances the queue.
|
/// `NowPlayingState`. When a track ends it auto-advances the queue.
|
||||||
|
///
|
||||||
|
/// Guarded against double-start: the shell can now be entered twice per
|
||||||
|
/// process (offline session, then the D2 recovery login runs the full
|
||||||
|
/// online entry over it) and a second loop would double the track-end
|
||||||
|
/// auto-advance.
|
||||||
pub fn start_poll_loop(
|
pub fn start_poll_loop(
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
weak: slint::Weak<AppWindow>,
|
weak: slint::Weak<AppWindow>,
|
||||||
handle: tokio::runtime::Handle,
|
handle: tokio::runtime::Handle,
|
||||||
) {
|
) {
|
||||||
|
static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
if STARTED.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let spawn_handle = handle.clone();
|
let spawn_handle = handle.clone();
|
||||||
spawn_handle.spawn(async move {
|
spawn_handle.spawn(async move {
|
||||||
// Track whether the last poll observed an active track, so the
|
// Track whether the last poll observed an active track, so the
|
||||||
|
|||||||
@@ -118,6 +118,15 @@ pub fn init(
|
|||||||
if TRAY.get().is_some() {
|
if TRAY.get().is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// On Linux TRAY is set asynchronously (inside the init thread below), so
|
||||||
|
// the OnceLock check alone leaves a window where a second shell entry
|
||||||
|
// (offline session -> D2 recovery login) would spawn a duplicate ksni
|
||||||
|
// tray. This synchronous flag closes it; checked after `enabled` so a
|
||||||
|
// disabled first call does not burn the one-shot.
|
||||||
|
static INIT_STARTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
if INIT_STARTED.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
|||||||
import { Toast } from "primitives/Toast.slint";
|
import { Toast } from "primitives/Toast.slint";
|
||||||
|
|
||||||
// Re-export the state globals so the Rust layer can populate them.
|
// 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 } 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 } from "state.slint";
|
||||||
|
|
||||||
// Which top-level screen is shown. The app starts on `splash` while it
|
// Which top-level screen is shown. The app starts on `splash` while it
|
||||||
// restores a saved session, then resolves to `shell` or `login`.
|
// restores a saved session, then resolves to `shell` or `login`.
|
||||||
@@ -56,6 +56,8 @@ export component AppWindow inherits Window {
|
|||||||
callback settings-release-device();
|
callback settings-release-device();
|
||||||
callback logout();
|
callback logout();
|
||||||
callback close-app();
|
callback close-app();
|
||||||
|
// D2 recovery banner: sign back in with the saved token.
|
||||||
|
callback recovery-login();
|
||||||
|
|
||||||
if root.screen == AppScreen.splash: SplashScreen { }
|
if root.screen == AppScreen.splash: SplashScreen { }
|
||||||
|
|
||||||
@@ -119,6 +121,9 @@ export component AppWindow inherits Window {
|
|||||||
close-app => {
|
close-app => {
|
||||||
root.close-app();
|
root.close-app();
|
||||||
}
|
}
|
||||||
|
recovery-login => {
|
||||||
|
root.recovery-login();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// App-global in-app toast overlay. Declared last so it floats over
|
// App-global in-app toast overlay. Declared last so it floats over
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Typography } from "../foundation/typography.slint";
|
|||||||
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
|
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
|
||||||
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
|
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
|
||||||
import { QbzTextLink } from "../primitives/QbzTextLink.slint";
|
import { QbzTextLink } from "../primitives/QbzTextLink.slint";
|
||||||
|
import { OfflineState } from "../state.slint";
|
||||||
|
|
||||||
export component LoginScreen inherits Rectangle {
|
export component LoginScreen inherits Rectangle {
|
||||||
in-out property <bool> tos-accepted: true;
|
in-out property <bool> tos-accepted: true;
|
||||||
@@ -129,7 +130,79 @@ export component LoginScreen inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rectangle { height: 10px; }
|
Rectangle { height: 10px; }
|
||||||
HorizontalLayout {
|
|
||||||
|
// --- Offline-boot callout (spec §4.1) --------------------
|
||||||
|
// No connectivity: point the user at the Start-offline link
|
||||||
|
// right below. Gated on has-previous-session too — the link
|
||||||
|
// it points at is hidden without prior user data.
|
||||||
|
if OfflineState.has-previous-session && OfflineState.connectivity == 2: VerticalLayout {
|
||||||
|
spacing: 2px;
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 12px;
|
||||||
|
spacing: 4px;
|
||||||
|
Text {
|
||||||
|
text: @tr("No internet connection — you can start in offline mode with your local library and downloads");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
if OfflineState.captive-portal: Text {
|
||||||
|
text: @tr("A network sign-in page may be blocking the connection");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Caret pointing at the Start-offline link below.
|
||||||
|
Text {
|
||||||
|
text: "▼";
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init-error variant of the same box (session restore
|
||||||
|
// failed for a non-connectivity reason). Hidden while the
|
||||||
|
// connectivity box shows — never two boxes at once.
|
||||||
|
if OfflineState.login-error != "" && OfflineState.connectivity != 2: VerticalLayout {
|
||||||
|
spacing: 0px;
|
||||||
|
Rectangle {
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 12px;
|
||||||
|
spacing: 4px;
|
||||||
|
Text {
|
||||||
|
text: @tr("Could not restore your session");
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: OfflineState.login-error;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { height: Spacing.sm; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if OfflineState.has-previous-session: HorizontalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
QbzTextLink {
|
QbzTextLink {
|
||||||
label: @tr("Start offline (no access to Qobuz™ services)");
|
label: @tr("Start offline (no access to Qobuz™ services)");
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Layout } from "../foundation/layout.slint";
|
import { Layout } from "../foundation/layout.slint";
|
||||||
import { Radius } from "../foundation/radius.slint";
|
import { Radius } from "../foundation/radius.slint";
|
||||||
import { NavState, ContentView, ShellState } from "../state.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
|
import { NavState, ContentView, ShellState, OfflineState } from "../state.slint";
|
||||||
|
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
|
||||||
import { Sidebar } from "Sidebar.slint";
|
import { Sidebar } from "Sidebar.slint";
|
||||||
import { HeaderBar } from "HeaderBar.slint";
|
import { HeaderBar } from "HeaderBar.slint";
|
||||||
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
|
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
|
||||||
@@ -69,6 +71,11 @@ export component AppShell inherits Rectangle {
|
|||||||
callback settings-release-device();
|
callback settings-release-device();
|
||||||
callback logout();
|
callback logout();
|
||||||
callback close-app();
|
callback close-app();
|
||||||
|
// D2 recovery banner: sign back in with the saved token.
|
||||||
|
callback recovery-login();
|
||||||
|
|
||||||
|
// The recovery banner participates in the content-height arithmetic below.
|
||||||
|
property <length> recovery-banner-height: OfflineState.show-recovery-banner ? 40px : 0px;
|
||||||
|
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
|
|
||||||
@@ -100,6 +107,35 @@ export component AppShell inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D2 recovery banner — an unauthenticated offline session while
|
||||||
|
// connectivity is back: one click signs back in with the saved token.
|
||||||
|
// In layout flow (below the header, above the content row), so the
|
||||||
|
// root-level modal overlays declared after the layout still cover it.
|
||||||
|
if OfflineState.show-recovery-banner: Rectangle {
|
||||||
|
height: root.recovery-banner-height;
|
||||||
|
background: Theme.accent.with-alpha(0.12);
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 12px;
|
||||||
|
padding-top: 3px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
Text {
|
||||||
|
text: @tr("Connection available — sign back in to Qobuz");
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
// SecondaryButton declares its TouchArea last internally, and
|
||||||
|
// the button is the last child here — nothing paints over it.
|
||||||
|
SecondaryButton {
|
||||||
|
label: @tr("Sign in");
|
||||||
|
clicked => {
|
||||||
|
root.recovery-login();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
@@ -143,7 +179,7 @@ export component AppShell inherits Rectangle {
|
|||||||
// truth for the bar height (New + Classic both use it).
|
// truth for the bar height (New + Classic both use it).
|
||||||
// When the Small / Large bar layouts land with a DIFFERENT
|
// When the Small / Large bar layouts land with a DIFFERENT
|
||||||
// height, make that value mode-aware and this stays correct.
|
// height, make that value mode-aware and this stays correct.
|
||||||
height: root.height - Layout.header-height - Layout.player-bar-height - 8px;
|
height: root.height - Layout.header-height - root.recovery-banner-height - Layout.player-bar-height - 8px;
|
||||||
vertical-stretch: 1;
|
vertical-stretch: 1;
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
border-radius: Radius.md;
|
border-radius: Radius.md;
|
||||||
|
|||||||
@@ -3093,3 +3093,31 @@ export global SearchActions {
|
|||||||
// Request the next page for the given tab.
|
// Request the next page for the given tab.
|
||||||
callback load-more(int);
|
callback load-more(int);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================ Offline MODE ================================
|
||||||
|
// App-wide offline MODE status (the app operating without Qobuz — NOT the
|
||||||
|
// offline CACHE/downloads). Mirrors `qbz_app::offline_mode::OfflineStatus`;
|
||||||
|
// pushed by crate::offline_mode::start_ui_forwarder on every engine
|
||||||
|
// broadcast. Read by the login screen (offline-boot affordances) and the
|
||||||
|
// shell (D2 recovery banner); the gating slice adds more consumers.
|
||||||
|
export global OfflineState {
|
||||||
|
// Any offline flavor active (mode != online).
|
||||||
|
in property <bool> offline: false;
|
||||||
|
// 0 = online, 1 = real offline, 2 = induced offline.
|
||||||
|
in property <int> mode: 0;
|
||||||
|
// Raw connectivity, independent of the mode: 0 unknown, 1 up, 2 down.
|
||||||
|
in property <int> connectivity: 0;
|
||||||
|
// A probe was answered by a redirect — typical captive portal.
|
||||||
|
in property <bool> captive-portal: false;
|
||||||
|
// D2: unauthenticated offline session while connectivity is back up —
|
||||||
|
// the shell shows the one-click "Sign in" banner.
|
||||||
|
in property <bool> show-recovery-banner: false;
|
||||||
|
// A previous login exists (last_user_id on disk). Gates the login
|
||||||
|
// screen's "Start offline" affordance: offline mode needs a prior
|
||||||
|
// user's data, never the empty user-0 profile.
|
||||||
|
in property <bool> has-previous-session: false;
|
||||||
|
// Startup session-restore failure detail, shown on the login screen
|
||||||
|
// ("" = none). Set by the restore task's error arm; cleared on any
|
||||||
|
// successful shell entry.
|
||||||
|
in property <string> login-error: "";
|
||||||
|
}
|
||||||
Reference in new issue
Block a user