fix(slint): offline badge rework, sidebar flyout drift, header MyQBZ routes

Recovery banner replaced by a tri-state header badge left of the menu
button (Offline hard amber / Offline manual accent / Logged out gray,
hidden when online and authenticated) with a flyout carrying the full
status line, a Sign in button for the logged-out state (enabled unless
connectivity is CONFIRMED down - it was born dead behind the Unknown
verdict; action fires before the popup closes) and a quick Offline-mode
toggle for the manual state (Settings shortcut dropped per owner).
Discover/Library nav entries are hidden offline instead of disabled, on
all three nav surfaces.

Sidebar flyout incremental drift root-caused by a headless harness against
slint 1.16.1: an opacity binding on SidebarNavRow's ROOT (left over from
the disabled-gating approach) forces an inlined opacity wrapper that makes
absolute-position double-count self.y - one row-pitch of drift per row,
which the in-layout banner had masked as its own bug. Binding removed;
anchors measure exact in all states. Same-class latent hazard flagged in
NavTab/CompactNavBtn (cancels today, self.x=0).

Header MyQBZ dropdown rows carried no route (documented no-op): Collections
and Mixtapes now navigate from both the full tabs and the compact rail.
The offline-edge watcher also reacts to connectivity-only changes so the
network-folder browse set refreshes in place during a logged-out session.

cc
This commit is contained in:
vicrodh committed 2026-06-10 01:32:44 -06:00
1 parent 77dfd73b6d
commit 4479052f7b
6 files changed
+407 -94

No files matched your search

+147 -15
View File
@@ -3549,10 +3549,40 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
tokio_rt.spawn(async move { tokio_rt.spawn(async move {
let mut rx = offline_mode::engine().subscribe(); let mut rx = offline_mode::engine().subscribe();
let mut was_offline = rx.borrow_and_update().is_offline(); let initial = *rx.borrow_and_update();
let mut was_offline = initial.is_offline();
let mut was_conn_down =
initial.connectivity == qbz_app::offline_mode::Connectivity::Down;
while rx.changed().await.is_ok() { while rx.changed().await.is_ok() {
let now_offline = rx.borrow_and_update().is_offline(); let status = *rx.borrow_and_update();
let now_offline = status.is_offline();
let now_conn_down =
status.connectivity == qbz_app::offline_mode::Connectivity::Down;
let conn_changed = now_conn_down != was_conn_down;
was_conn_down = now_conn_down;
if now_offline == was_offline { if now_offline == was_offline {
// Connectivity flipped WITHOUT a mode change (e.g. the
// link dying or returning during a logged-out session):
// the connectivity-keyed network-folder gate changes the
// browse SET, so refresh LocalLibrary in place.
if conn_changed {
let runtime2 = runtime.clone();
let nav_weak = weak.clone();
let handle2 = handle.clone();
let image_cache2 = image_cache.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
local_library::reset_browse_models(&w);
if w.global::<NavState>().get_view() == ContentView::LocalLibrary {
let tab = local_library::LibTab::from_tab_id(
&w.global::<LocalLibraryState>().get_active_tab(),
)
.unwrap_or(local_library::LibTab::Albums);
navigate_local_library(
runtime2, nav_weak, &handle2, image_cache2, tab,
);
}
});
}
continue; continue;
} }
was_offline = now_offline; was_offline = now_offline;
@@ -3560,6 +3590,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Back online: refresh the sidebar with the real Qobuz set // Back online: refresh the sidebar with the real Qobuz set
// (the offline cache may hold synthesized names). // (the offline cache may hold synthesized names).
load_sidebar_playlists(runtime.clone(), weak.clone(), &handle); load_sidebar_playlists(runtime.clone(), weak.clone(), &handle);
// Drop the LocalLibrary browse sets so the next visit
// re-fetches under the new state (the connectivity-keyed
// network-folder gate may change the SET), and reload in
// place when the user is standing there.
let runtime2 = runtime.clone();
let nav_weak = weak.clone();
let handle2 = handle.clone();
let image_cache2 = image_cache.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
local_library::reset_browse_models(&w);
if w.global::<NavState>().get_view() == ContentView::LocalLibrary {
let tab = local_library::LibTab::from_tab_id(
&w.global::<LocalLibraryState>().get_active_tab(),
)
.unwrap_or(local_library::LibTab::Albums);
navigate_local_library(
runtime2,
nav_weak,
&handle2,
image_cache2,
tab,
);
}
});
continue; continue;
} }
let runtime = runtime.clone(); let runtime = runtime.clone();
@@ -3571,6 +3625,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// (the D11.b filter lives in sidebar::rebuild). // (the D11.b filter lives in sidebar::rebuild).
sidebar::rebuild(&w); sidebar::rebuild(&w);
refresh_sidebar_covers(&w); refresh_sidebar_covers(&w);
// Drop the browse sets so the next fetch (incl. the D12b
// navigation below) re-derives under offline. The SET is
// identical (network content is never hidden); the reset
// only refreshes per-row availability chrome.
local_library::reset_browse_models(&w);
match w.global::<NavState>().get_view() { match w.global::<NavState>().get_view() {
// D11.c: refresh the open grid/detail so unavailable // D11.c: refresh the open grid/detail so unavailable
// items (and all-unavailable collections) drop. // items (and all-unavailable collections) drop.
@@ -3602,6 +3661,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
} }
} }
ContentView::LocalLibrary => {
// Standing on a browse tab: the models were just
// reset — reload the active tab in place so the
// grid re-fetches under the offline gate instead
// of sitting empty until re-entry.
let tab = local_library::LibTab::from_tab_id(
&w.global::<LocalLibraryState>().get_active_tab(),
)
.unwrap_or(local_library::LibTab::Albums);
navigate_local_library(
runtime.clone(),
nav_weak.clone(),
&handle2,
image_cache.clone(),
tab,
);
}
_ => { _ => {
// D12b: blocked Qobuz view → LocalLibrary. // D12b: blocked Qobuz view → LocalLibrary.
if is_offline_blocked_view(&w) { if is_offline_blocked_view(&w) {
@@ -3737,16 +3813,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let settings_ctx = settings_ctx.clone(); let settings_ctx = settings_ctx.clone();
window.on_recovery_login(move || { window.on_recovery_login(move || {
// Logged BEFORE the spawn: records the click arriving from the
// UI chain even if the async attempt below stalls or fails.
log::info!("[qbz-slint] recovery sign-in requested");
let runtime = runtime.clone(); let runtime = runtime.clone();
let weak = weak.clone(); let weak = weak.clone();
let image_cache = image_cache.clone(); let image_cache = image_cache.clone();
let settings_ctx = settings_ctx.clone(); let settings_ctx = settings_ctx.clone();
handle.spawn(async move { handle.spawn(async move {
// The offline session keeps the Qobuz gate closed (D3), which // No pre-lift needed: the auth endpoints are EXEMPT from the
// would refuse the recovery login itself. Lift the session // offline gate (qbz-qobuz client), so the token login and the
// flag for the attempt; restore it on failure so the gate // OAuth exchange pass the closed gate. Net effect: the
// closes again and the banner returns (connectivity is up). // offline_session flag ends up false only on SUCCESS paths —
offline_mode::engine().set_offline_session(false); // restore_saved_session clears it on success only, and the
// browser fallback's upfront clear is restored on failure
// below — so a failed attempt leaves the live offline session
// (and the recovery banner) intact.
match auth::restore_saved_session(&runtime).await { match auth::restore_saved_session(&runtime).await {
Ok(Some(session)) => { Ok(Some(session)) => {
log::info!( log::info!(
@@ -3756,17 +3838,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
enter_shell(runtime, weak, image_cache, settings_ctx, session).await; enter_shell(runtime, weak, image_cache, settings_ctx, session).await;
} }
Ok(None) => { Ok(None) => {
log::warn!("[qbz-slint] recovery login: saved session no longer usable"); // No saved token, or the token was explicitly
offline_mode::engine().set_offline_session(true); // rejected (and cleared). The user asked to sign in —
let _ = weak.upgrade_in_event_loop(|w| { // fall back to the full system-browser OAuth.
toast::error(&w, "Sign-in failed — the saved session is no longer valid"); log::warn!(
w.global::<OfflineState>() "[qbz-slint] recovery login: saved session unusable — falling back to browser OAuth"
.set_login_error("Saved session is no longer valid".into()); );
}); match auth::login_via_system_browser(&runtime).await {
Ok(session) => {
log::info!(
"[qbz-slint] recovery browser sign-in succeeded for user {}",
session.user_id
);
enter_shell(runtime, weak, image_cache, settings_ctx, session)
.await;
}
Err(e) => {
log::error!("[qbz-slint] recovery browser sign-in failed: {e}");
// login_via_system_browser ends the offline
// session upfront (login-screen semantics);
// here the user is still inside the
// unauthenticated offline shell — restore the
// session flag so the gate closes again and
// the banner returns.
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());
});
}
}
} }
Err(e) => { Err(e) => {
// Network-class failure: the offline_session flag was
// never lifted (no pre-lift), so the offline shell
// state is already intact — just surface the error.
log::error!("[qbz-slint] recovery login failed: {e}"); log::error!("[qbz-slint] recovery login failed: {e}");
offline_mode::engine().set_offline_session(true);
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
toast::error(&w, format!("Sign-in failed: {e}")); toast::error(&w, format!("Sign-in failed: {e}"));
w.global::<OfflineState>().set_login_error(e.into()); w.global::<OfflineState>().set_login_error(e.into());
@@ -4180,6 +4287,31 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
offline_mode::check_now(weak.clone(), handle.clone()); offline_mode::check_now(weak.clone(), handle.clone());
}); });
} }
// The header badge flyout's quick offline toggle — same persistence +
// #279 snapshot path as the Settings "Enable Offline Mode" toggle.
{
let runtime = app_runtime.clone();
let settings_ctx = settings_ctx.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<OfflineModeActions>()
.on_set_offline(move |value| {
let runtime = runtime.clone();
let settings_ctx = settings_ctx.clone();
let weak = weak.clone();
handle.spawn(async move {
settings::handle_bool(
settings_ctx,
runtime,
weak,
"offline-mode-enabled".to_string(),
value,
)
.await;
});
});
}
// Appearance settings persistence. The toggles/selects set their // Appearance settings persistence. The toggles/selects set their
// AppearanceState property locally, then fire these generic callbacks so // AppearanceState property locally, then fire these generic callbacks so
+13 -7
View File
@@ -83,9 +83,9 @@ pub fn check_now(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
}); });
} }
/// Seed the Settings > Offline MODE toggle states from the persisted /// Seed the Settings > Offline MODE toggle state from the persisted
/// engine store. Fired by the panel's `init` (`OfflineModeActions.load`), /// engine store. Fired by the panel's `init` (`OfflineModeActions.load`),
/// so every mount of Settings > Offline re-reads them — the same lazy-load /// so every mount of Settings > Offline re-reads it — the same lazy-load
/// hook LocalLibrarySettings uses. Best-effort: pre-session reads (no /// hook LocalLibrarySettings uses. Best-effort: pre-session reads (no
/// store bound) keep the defaults. /// store bound) keep the defaults.
pub fn seed_settings(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) { pub fn seed_settings(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
@@ -102,9 +102,8 @@ pub fn seed_settings(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handl
} }
}; };
let _ = weak.upgrade_in_event_loop(move |w| { let _ = weak.upgrade_in_event_loop(move |w| {
let st = w.global::<SettingsState>(); w.global::<SettingsState>()
st.set_offline_mode_enabled(settings.manual_offline_mode); .set_offline_mode_enabled(settings.manual_offline_mode);
st.set_offline_show_network_folders(settings.show_network_folders_in_manual_offline);
}); });
}); });
} }
@@ -150,6 +149,10 @@ fn apply_status(w: &AppWindow, status: OfflineStatus) {
}); });
state.set_captive_portal(status.captive_portal); state.set_captive_portal(status.captive_portal);
state.set_show_recovery_banner(status.show_recovery_banner()); state.set_show_recovery_banner(status.show_recovery_banner());
// The header badge's "Logged out" state needs the raw session flag —
// show_recovery_banner() is false while connectivity is down, but the
// badge must still read "Logged out" then.
state.set_offline_session(status.offline_session);
// A status broadcast resolves any in-flight Settings "Check now". // A status broadcast resolves any in-flight Settings "Check now".
w.global::<SettingsState>().set_offline_checking(false); w.global::<SettingsState>().set_offline_checking(false);
} }
@@ -188,8 +191,11 @@ pub fn init_for_user(base_dir: &Path) {
spawn_subscription_purge_check(); spawn_subscription_purge_check();
} }
/// Drop the per-user state on logout. The engine keeps its in-memory mode /// Drop the per-user state on logout. The engine also ends the
/// (the Qobuz gate must survive the transition); only the stores close. /// session-scoped offline state (offline_session + cached induced flag) and
/// reopens the Qobuz gate when connectivity allows — a logged-out user must
/// always be able to sign back in. The persisted induced preference reloads
/// from disk on the next session activation.
pub fn teardown() { pub fn teardown() {
engine().teardown(); engine().teardown();
if let Ok(mut guard) = SUBSCRIPTION.lock() { if let Ok(mut guard) = SUBSCRIPTION.lock() {
+9 -35
View File
@@ -8,9 +8,7 @@
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 { Typography } from "../foundation/typography.slint";
import { NavState, ContentView, ShellState, OfflineState, PlaylistState } from "../state.slint"; import { NavState, ContentView, ShellState, OfflineState, PlaylistState } 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";
@@ -72,12 +70,10 @@ 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. // D2 recovery sign-in with the saved token — fired by the header
// status badge's flyout (formerly an in-layout banner).
callback recovery-login(); callback recovery-login();
// The recovery banner participates in the content-height arithmetic below.
property <length> recovery-banner-height: OfflineState.show-recovery-banner ? 40px : 0px;
// Offline gating (slice 3d): the Qobuz-only content views are replaced by // Offline gating (slice 3d): the Qobuz-only content views are replaced by
// the OfflinePlaceholder while ANY offline flavor is active. Views that // the OfflinePlaceholder while ANY offline flavor is active. Views that
// work without Qobuz (LocalLibrary, local album, offline cache manager, // work without Qobuz (LocalLibrary, local album, offline cache manager,
@@ -120,6 +116,10 @@ export component AppShell inherits Rectangle {
spacing: 0px; spacing: 0px;
// Header spans the full width at the top; the sidebar starts below it. // Header spans the full width at the top; the sidebar starts below it.
// The offline/recovery status now lives INSIDE the header (badge +
// flyout, left of the app-menu button) — never as an in-layout row,
// which displaced everything below it (the sidebar and its anchored
// section flyouts) by its height.
HeaderBar { HeaderBar {
logout => { logout => {
root.logout(); root.logout();
@@ -127,34 +127,8 @@ export component AppShell inherits Rectangle {
close-app => { close-app => {
root.close-app(); root.close-app();
} }
} recovery-login => {
root.recovery-login();
// 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();
}
}
} }
} }
@@ -201,7 +175,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 - root.recovery-banner-height - Layout.player-bar-height - 8px; height: root.height - Layout.header-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;
+205 -22
View File
@@ -9,19 +9,24 @@
// below the second the search field narrows. // below the second the search field narrows.
import { Theme } from "../foundation/semantic-colors.slint"; import { Theme } from "../foundation/semantic-colors.slint";
import { Palette } from "../foundation/palette.slint";
import { Spacing } from "../foundation/spacing.slint"; import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.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 { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions, SidebarPlaylistsPopupState, OfflineState } from "../state.slint"; import { SessionState, NavState, ContentView, ShellState, HeaderMenuState, NavMenuEntry, SearchActions, SidebarPlaylistsPopupState, OfflineState, SettingsState, OfflineModeActions } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { SecondaryButton } from "../primitives/SecondaryButton.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
component NavTab inherits Rectangle { component NavTab inherits Rectangle {
in property <image> icon; in property <image> icon;
in property <string> label; in property <string> label;
in property <bool> active: false; in property <bool> active: false;
in property <bool> show-icon: true; in property <bool> show-icon: true;
// Offline gating: dimmed + pointer-inert (no click, no hover-to-open). // Dimmed + pointer-inert (no click, no hover-to-open). NOTE: no longer
// used for offline gating — offline tabs are HIDDEN at the mount site
// (`if` per ADR-010). Kept for future use.
in property <bool> disabled: false; in property <bool> disabled: false;
callback clicked(); callback clicked();
// Fires when the pointer first enters the tab — drives hover-to-open. // Fires when the pointer first enters the tab — drives hover-to-open.
@@ -133,7 +138,8 @@ component NavTabWithMenu inherits Rectangle {
in property <string> label; in property <string> label;
in property <bool> active: false; in property <bool> active: false;
in property <bool> show-icon: true; in property <bool> show-icon: true;
// Offline gating — forwarded to the inner NavTab. // Forwarded to the inner NavTab. NOTE: no longer used for offline
// gating (offline tabs are hidden at the mount site). Kept for future use.
in property <bool> disabled: false; in property <bool> disabled: false;
in property <[NavMenuEntry]> items; in property <[NavMenuEntry]> items;
// This tab's index (0..3) — written into HeaderMenuState.open-index. // This tab's index (0..3) — written into HeaderMenuState.open-index.
@@ -197,7 +203,8 @@ component CompactNavBtn inherits Rectangle {
in property <[NavMenuEntry]> items; in property <[NavMenuEntry]> items;
in property <int> tab-index; in property <int> tab-index;
in property <int> home-item: -1; in property <int> home-item: -1;
// Offline gating: dimmed + pointer-inert. // Dimmed + pointer-inert. NOTE: no longer used for offline gating
// (offline buttons are hidden at the mount site). Kept for future use.
in property <bool> disabled: false; in property <bool> disabled: false;
width: 30px; width: 30px;
@@ -340,9 +347,175 @@ component HeaderIconButton inherits Rectangle {
} }
} }
// Offline status badge — sits immediately LEFT of the app-menu button.
// Tri-state (OfflineState.badge-state): 1 = hard offline (amber cloud-off),
// 2 = manual/induced offline (accent cloud-off), 3 = logged out (muted user
// glyph; wins over the other two — it's the actionable fact). Not mounted
// at all when online + authenticated. NOT a pill (ADR-008): ghost chrome
// styling like NavTab — transparent, hover -> surface-hover, Radius.sm.
// Clicking opens a small flyout with the full status line plus the state's
// action (Sign in / Open Settings).
component OfflineStatusBadge inherits Rectangle {
// Fires the AppShell recovery-login() chain (the same saved-token
// sign-in the old recovery banner used).
callback recovery-login();
property <int> badge-state: OfflineState.badge-state;
property <bool> connectivity-up: OfflineState.connectivity == 1;
// Only a CONFIRMED-down link disables sign-in. Unknown (0, pre-first-
// probe — e.g. right after app start) must NOT gate the button: the
// attempt is harmless and fails into a visible toast if the network is
// really absent, while a dead button reads as "does nothing".
property <bool> connectivity-down: OfflineState.connectivity == 2;
// Icon tint per state: warning/amber = hard, accent = manual,
// muted = logged out. Same mapping Settings > Offline's status row uses.
property <color> state-tint: root.badge-state == 1 ? Palette.warning
: root.badge-state == 2 ? Theme.accent
: Theme.text-muted;
height: 30px;
width: badge-row.preferred-width;
border-radius: Radius.sm;
background: badge-ta.has-hover ? Theme.surface-hover : transparent;
badge-row := HorizontalLayout {
padding-left: 9px;
padding-right: 11px;
spacing: 6px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.badge-state == 3
? @image-url("../assets/icons/user.svg")
: @image-url("../assets/icons/cloud-off.svg");
width: 14px;
height: 14px;
tint: root.state-tint;
}
}
Text {
text: root.badge-state == 3 ? @tr("Logged out")
: root.badge-state == 2 ? @tr("Offline (manual)")
: @tr("Offline (hard)");
height: root.height;
vertical-alignment: center;
color: Theme.text-secondary;
font-size: 11px;
}
}
badge-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
status-flyout.show();
}
}
// Status flyout — the former banner texts + actions, right edge aligned
// with the badge. PopupWindow footgun: explicit height + close-policy.
// Buttons close it explicitly before acting.
status-flyout := PopupWindow {
x: root.width - 320px;
y: root.height + 6px;
width: 320px;
height: root.badge-state == 1 ? 74px
: root.badge-state == 2 ? 108px
: 116px;
close-policy: PopupClosePolicy.close-on-click-outside;
Rectangle {
background: Theme.surface-main;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-muted;
drop-shadow-blur: 24px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
VerticalLayout {
padding: 14px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: start;
QbzIcon {
source: root.badge-state == 3
? @image-url("../assets/icons/user.svg")
: @image-url("../assets/icons/cloud-off.svg");
width: 15px;
height: 15px;
tint: root.state-tint;
}
}
Text {
// Full status line per state; logged-out also keys
// on raw connectivity (1 up / 2 down / 0 unknown).
text: root.badge-state == 3
? (root.connectivity-down
? @tr("You're signed out. Sign-in needs a connection.")
: root.connectivity-up
? @tr("Connection available — sign back in to Qobuz.")
: @tr("You're signed out — sign back in to Qobuz."))
: root.badge-state == 2
? @tr("Offline mode is enabled. Disable it in Settings to use Qobuz.")
: @tr("No internet connection — your local library and downloads keep working.");
color: Theme.text-primary;
font-size: 12px;
wrap: word-wrap;
horizontal-stretch: 1;
}
}
// Sign in — logged-out state only; disabled only when the
// link is CONFIRMED down (Unknown still allows the attempt).
// Fires the existing recovery-login() chain.
if root.badge-state == 3: HorizontalLayout {
alignment: end;
SecondaryButton {
label: @tr("Sign in");
enabled: !root.connectivity-down;
clicked => {
// Fire the chain BEFORE close(): the popup
// teardown must never be able to swallow it
// (same action-then-close ordering as the
// PlayerBar's qconnect menu items).
root.recovery-login();
status-flyout.close();
}
}
}
// Manual/induced state: quick toggle straight back to online
// (same persistence + #279 path as the Settings toggle). The
// toggle is the whole control set here — owner dropped the
// Settings shortcut (2026-06-10).
if root.badge-state == 2: HorizontalLayout {
spacing: 8px;
Text {
text: @tr("Offline mode");
color: Theme.text-secondary;
font-size: 12px;
vertical-alignment: center;
horizontal-stretch: 1;
}
QbzToggle {
checked: true;
toggled(value) => {
OfflineModeActions.set-offline(value);
status-flyout.close();
}
}
}
}
}
}
}
export component HeaderBar inherits Rectangle { export component HeaderBar inherits Rectangle {
callback logout(); callback logout();
callback close-app(); callback close-app();
// Forwarded up to AppShell — the badge flyout's "Sign in" (saved-token
// re-login during an unauthenticated offline session).
callback recovery-login();
height: Layout.header-height; height: Layout.header-height;
background: Theme.surface-card; background: Theme.surface-card;
@@ -448,7 +621,9 @@ export component HeaderBar inherits Rectangle {
if !ShellState.nav-in-sidebar && ShellState.sidebar-state != 2: HorizontalLayout { if !ShellState.nav-in-sidebar && ShellState.sidebar-state != 2: HorizontalLayout {
spacing: 2px; spacing: 2px;
Rectangle { width: 6px; } Rectangle { width: 6px; }
VerticalLayout { // Qobuz-only — HIDDEN entirely while offline (ADR-010 `if` at
// the mount site, not rendered-disabled).
if !OfflineState.offline: VerticalLayout {
alignment: center; alignment: center;
NavTabWithMenu { NavTabWithMenu {
tab-index: 0; tab-index: 0;
@@ -456,8 +631,6 @@ export component HeaderBar inherits Rectangle {
label: @tr("Discover"); label: @tr("Discover");
active: true; active: true;
show-icon: root.show-tab-icons; show-icon: root.show-tab-icons;
// Qobuz-only — inert while offline.
disabled: OfflineState.offline;
home-item: 0; home-item: 0;
items: [ items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" }, { label: @tr("Home"), has-glyph: false, route: "discover-home" },
@@ -466,15 +639,14 @@ export component HeaderBar inherits Rectangle {
]; ];
} }
} }
VerticalLayout { // Qobuz favorites — HIDDEN entirely while offline.
if !OfflineState.offline: VerticalLayout {
alignment: center; alignment: center;
NavTabWithMenu { NavTabWithMenu {
tab-index: 1; tab-index: 1;
icon: @image-url("../assets/icons/music-library-2.svg"); icon: @image-url("../assets/icons/music-library-2.svg");
label: @tr("Library"); label: @tr("Library");
show-icon: root.show-tab-icons; show-icon: root.show-tab-icons;
// Qobuz favorites — inert while offline.
disabled: OfflineState.offline;
items: [ items: [
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
@@ -507,8 +679,8 @@ export component HeaderBar inherits Rectangle {
label: @tr("My QBZ"); label: @tr("My QBZ");
show-icon: root.show-tab-icons; show-icon: root.show-tab-icons;
items: [ items: [
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true }, { label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true, route: "myqbz-collections" },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true }, { label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true, route: "myqbz-mixtapes" },
]; ];
} }
} }
@@ -520,14 +692,14 @@ export component HeaderBar inherits Rectangle {
spacing: 2px; spacing: 2px;
// A small gap from the history buttons. // A small gap from the history buttons.
Rectangle { width: 6px; } Rectangle { width: 6px; }
VerticalLayout { // Qobuz-only — HIDDEN entirely while offline (ADR-010 `if` at
// the mount site, not rendered-disabled).
if !OfflineState.offline: VerticalLayout {
alignment: center; alignment: center;
CompactNavBtn { CompactNavBtn {
tab-index: 0; tab-index: 0;
title: @tr("Discover"); title: @tr("Discover");
icon: @image-url("../assets/icons/compass.svg"); icon: @image-url("../assets/icons/compass.svg");
// Qobuz-only — inert while offline.
disabled: OfflineState.offline;
home-item: 0; home-item: 0;
items: [ items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" }, { label: @tr("Home"), has-glyph: false, route: "discover-home" },
@@ -536,14 +708,13 @@ export component HeaderBar inherits Rectangle {
]; ];
} }
} }
VerticalLayout { // Qobuz favorites — HIDDEN entirely while offline.
if !OfflineState.offline: VerticalLayout {
alignment: center; alignment: center;
CompactNavBtn { CompactNavBtn {
tab-index: 1; tab-index: 1;
title: @tr("Library"); title: @tr("Library");
icon: @image-url("../assets/icons/music-library-2.svg"); icon: @image-url("../assets/icons/music-library-2.svg");
// Qobuz favorites — inert while offline.
disabled: OfflineState.offline;
items: [ items: [
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
@@ -574,8 +745,8 @@ export component HeaderBar inherits Rectangle {
title: @tr("My QBZ"); title: @tr("My QBZ");
icon: @image-url("../assets/icons/cassette-tape.svg"); icon: @image-url("../assets/icons/cassette-tape.svg");
items: [ items: [
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true }, { label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true, route: "myqbz-collections" },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true }, { label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true, route: "myqbz-mixtapes" },
]; ];
} }
} }
@@ -594,12 +765,24 @@ export component HeaderBar inherits Rectangle {
} }
} }
// --- Right controls: app menu (anchored right) ------------------------- // --- Right controls: status badge + app menu (anchored right) ----------
right-controls := HorizontalLayout { right-controls := HorizontalLayout {
x: root.width - self.width - Spacing.md + 2px; x: root.width - self.width - Spacing.md + 2px;
y: (root.height - self.height) / 2; y: (root.height - self.height) / 2;
width: 36px; width: self.preferred-width;
height: 36px; height: 36px;
spacing: 4px;
// Offline status badge — mounted only when there is something to
// say (ADR-010). Lives INSIDE this fixed-height header row, so it
// can never affect the shell's vertical layout.
if OfflineState.badge-state != 0: VerticalLayout {
alignment: center;
OfflineStatusBadge {
recovery-login => {
root.recovery-login();
}
}
}
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
HeaderIconButton { HeaderIconButton {
+14 -11
View File
@@ -529,15 +529,20 @@ component SidebarNavRow inherits Rectangle {
// (no theme tint). Used by the My QBZ row to show a custom icon with its // (no theme tint). Used by the My QBZ row to show a custom icon with its
// own colours; default branded glyphs stay tinted via QbzIcon. // own colours; default branded glyphs stay tinted via QbzIcon.
in property <bool> raw-icon: false; in property <bool> raw-icon: false;
// Offline gating: the row stays visible (layout stability) but dims and // Ignores the pointer (TouchArea disabled). NOTE: no longer used for
// ignores the pointer — the TouchArea is disabled, so neither click nor // offline gating — offline rows are HIDDEN at the mount site (`if` per
// hover can open the flyout. // ADR-010), not rendered-disabled. Kept for future use, but WITHOUT the
// old `opacity: disabled ? 0.5 : 1.0` root binding: an opacity binding
// on a component ROOT makes Slint 1.16 wrap the element, and
// `absolute-position` then double-counts the row's own layout offset
// (measured: anchor-y = true_y + self.y, so each flyout drifted one
// extra row-pitch per row). If a dim is ever needed again, apply the
// opacity to the row's inner content, never to this root.
in property <bool> disabled: false; in property <bool> disabled: false;
property <bool> active: HeaderMenuState.open-index == root.tab-index; property <bool> active: HeaderMenuState.open-index == root.tab-index;
height: 34px; height: 34px;
border-radius: 6px; border-radius: 6px;
opacity: root.disabled ? 0.5 : 1.0;
background: nav-ta.has-hover ? Theme.surface-hover : transparent; background: nav-ta.has-hover ? Theme.surface-hover : transparent;
// Publish this row's menu into the shared overlay, anchored to the right // Publish this row's menu into the shared overlay, anchored to the right
@@ -622,13 +627,12 @@ export component Sidebar inherits Rectangle {
// exactly like the header tabs did (same item sets). ---------- // exactly like the header tabs did (same item sets). ----------
if ShellState.nav-in-sidebar: VerticalLayout { if ShellState.nav-in-sidebar: VerticalLayout {
spacing: 2px; spacing: 2px;
SidebarNavRow { // Qobuz-only section — HIDDEN entirely while offline (Local
// Library and My QBZ below stay; ADR-010 `if` at the mount site).
if !OfflineState.offline: SidebarNavRow {
tab-index: 0; tab-index: 0;
icon: @image-url("../assets/icons/compass.svg"); icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover"); label: @tr("Discover");
// Qobuz-only section — inert while offline (Local Library and
// My QBZ below stay active).
disabled: OfflineState.offline;
home-item: 0; home-item: 0;
items: [ items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" }, { label: @tr("Home"), has-glyph: false, route: "discover-home" },
@@ -636,12 +640,11 @@ export component Sidebar inherits Rectangle {
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" }, { label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
]; ];
} }
SidebarNavRow { // Qobuz favorites — HIDDEN entirely while offline.
if !OfflineState.offline: SidebarNavRow {
tab-index: 1; tab-index: 1;
icon: @image-url("../assets/icons/music-library-2.svg"); icon: @image-url("../assets/icons/music-library-2.svg");
label: @tr("Library"); label: @tr("Library");
// Qobuz favorites — inert while offline.
disabled: OfflineState.offline;
items: [ items: [
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
+19 -4
View File
@@ -2075,10 +2075,8 @@ export global SettingsState {
// Settings > Offline, MODE group (the app operating without Qobuz — NOT // Settings > Offline, MODE group (the app operating without Qobuz — NOT
// the offline cache). Backed by the shared engine's per-user store, not // the offline cache). Backed by the shared engine's per-user store, not
// the audio/playback stores: seeded by OfflineModeActions.load() on panel // the audio/playback stores: seeded by OfflineModeActions.load() on panel
// mount; persisted through settings-bool keys "offline-mode-enabled" / // mount; persisted through the settings-bool key "offline-mode-enabled".
// "offline-show-network-folders".
in-out property <bool> offline-mode-enabled: false; 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. // "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 // Cleared by Rust on the next status broadcast, or after a short timeout
// when the verdict comes back unchanged (the actor only broadcasts flips). // when the verdict comes back unchanged (the actor only broadcasts flips).
@@ -3156,8 +3154,13 @@ export global OfflineState {
// A probe was answered by a redirect — typical captive portal. // A probe was answered by a redirect — typical captive portal.
in property <bool> captive-portal: false; in property <bool> captive-portal: false;
// D2: unauthenticated offline session while connectivity is back up — // D2: unauthenticated offline session while connectivity is back up —
// the shell shows the one-click "Sign in" banner. // drives the header badge's "Sign in" affordance (formerly a banner).
in property <bool> show-recovery-banner: false; in property <bool> show-recovery-banner: false;
// Session started without Qobuz auth ("Start offline" on the login
// screen). Unlike show-recovery-banner this is true regardless of
// connectivity — the badge's "Logged out" state needs it even while
// the link is down.
in property <bool> offline-session: false;
// A previous login exists (last_user_id on disk). Gates the login // A previous login exists (last_user_id on disk). Gates the login
// screen's "Start offline" affordance: offline mode needs a prior // screen's "Start offline" affordance: offline mode needs a prior
// user's data, never the empty user-0 profile. // user's data, never the empty user-0 profile.
@@ -3166,6 +3169,14 @@ export global OfflineState {
// ("" = none). Set by the restore task's error arm; cleared on any // ("" = none). Set by the restore task's error arm; cleared on any
// successful shell entry. // successful shell entry.
in property <string> login-error: ""; in property <string> login-error: "";
// Header status-badge state, derived: 0 = hidden (online + authenticated),
// 1 = hard offline (no connectivity), 2 = manual offline (induced),
// 3 = logged out (offline session — the actionable fact, so it takes
// precedence over the other two).
out property <int> badge-state: self.offline-session ? 3
: self.mode == 2 ? 2
: self.mode == 1 ? 1
: 0;
} }
// Settings > Offline, MODE group — Rust-wired actions (crate::offline_mode). // Settings > Offline, MODE group — Rust-wired actions (crate::offline_mode).
@@ -3176,4 +3187,8 @@ export global OfflineModeActions {
// Force an immediate connectivity re-probe (the status row's "Check // Force an immediate connectivity re-probe (the status row's "Check
// now"). The status text updates by itself via the engine forwarder. // now"). The status text updates by itself via the engine forwarder.
callback check-now(); callback check-now();
// Flip induced offline from anywhere outside the Settings panel (the
// header badge flyout's quick toggle). Routes through the same
// persistence + #279 snapshot path as the Settings toggle.
callback set-offline(bool);
} }