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:
6 files changed
+407
-94
No files matched your search
+147
-15
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user