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:
vicrodh committed 2026-06-09 20:31:07 -06:00
1 parent 904b5f7b40
commit 38e0b4be27
8 files changed
+275 -6

No files matched your search

+6 -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 } 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
// restores a saved session, then resolves to `shell` or `login`.
@@ -56,6 +56,8 @@ export component AppWindow inherits Window {
callback settings-release-device();
callback logout();
callback close-app();
// D2 recovery banner: sign back in with the saved token.
callback recovery-login();
if root.screen == AppScreen.splash: SplashScreen { }
@@ -119,6 +121,9 @@ export component AppWindow inherits Window {
close-app => {
root.close-app();
}
recovery-login => {
root.recovery-login();
}
}
// App-global in-app toast overlay. Declared last so it floats over
+74 -1
View File
@@ -14,6 +14,7 @@ import { Typography } from "../foundation/typography.slint";
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
import { QbzPrimaryButton } from "../primitives/QbzPrimaryButton.slint";
import { QbzTextLink } from "../primitives/QbzTextLink.slint";
import { OfflineState } from "../state.slint";
export component LoginScreen inherits Rectangle {
in-out property <bool> tos-accepted: true;
@@ -129,7 +130,79 @@ export component LoginScreen inherits Rectangle {
}
}
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;
QbzTextLink {
label: @tr("Start offline (no access to Qobuz™ services)");
+38 -2
View File
@@ -8,7 +8,9 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.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 { HeaderBar } from "HeaderBar.slint";
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
@@ -69,6 +71,11 @@ export component AppShell inherits Rectangle {
callback settings-release-device();
callback logout();
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;
@@ -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 {
vertical-stretch: 1;
spacing: 0px;
@@ -143,7 +179,7 @@ export component AppShell inherits Rectangle {
// truth for the bar height (New + Classic both use it).
// When the Small / Large bar layouts land with a DIFFERENT
// 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;
background: Theme.surface-main;
border-radius: Radius.md;
+28
View File
@@ -3093,3 +3093,31 @@ export global SearchActions {
// Request the next page for the given tab.
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: "";
}