From 1992a8187d24904af8ebacae8f36e56faeb76e7b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 16 Jul 2026 22:53:03 +0200 Subject: [PATCH] layout and remote fix --- src-tauri/src/playback_mode/mod.rs | 35 +++++++-- src/lib/components/BottomUi.svelte | 64 +++++++++++++++ src/lib/stores/appState.ts | 4 - src/lib/stores/playbackMode.test.ts | 63 +++++++++++++++ src/lib/stores/playbackMode.ts | 25 +++++- src/lib/utils/layoutShell.test.ts | 85 +++++++++----------- src/lib/utils/layoutShell.ts | 53 ++++--------- src/routes/+layout.svelte | 118 ++++++++-------------------- src/routes/library/+layout.svelte | 15 ++-- src/routes/settings/+page.svelte | 2 +- 10 files changed, 272 insertions(+), 192 deletions(-) create mode 100644 src/lib/components/BottomUi.svelte diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index 10f51cc2..e1e1a017 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -113,6 +113,26 @@ impl PlaybackModeManager { } } + /// Start the Android playback service and hand it remote-volume control. + /// + /// Must run on EVERY transition into remote mode, because it is what starts + /// the foreground service. Without a running service there is no media + /// notification (the lockscreen card is missing) AND system volume buttons + /// aren't intercepted for the remote session (remote volume control dead). + /// Both symptoms share this one cause, so this must not be skipped on any + /// remote-entry path (notably the empty-queue early return in + /// `transfer_to_remote_inner`). No-op / non-Android builds do nothing. + #[allow(unused_variables)] + fn enable_remote_control(&self) { + #[cfg(target_os = "android")] + { + if let Err(e) = crate::player::enable_remote_volume(50) { + log::warn!("[PlaybackMode] Failed to enable remote volume/service: {}", e); + // Non-fatal - continue; the next poll tick will retry metadata. + } + } + } + /// Check if currently transferring pub fn is_transferring(&self) -> bool { self.is_transferring.load(Ordering::Relaxed) @@ -334,6 +354,10 @@ impl PlaybackModeManager { self.set_mode(PlaybackMode::Remote { session_id: session_id.to_string(), }); + // Start the service + remote-volume control here too — otherwise this + // early return leaves remote mode with no media notification and no + // volume interception (lockscreen card missing + remote volume dead). + self.enable_remote_control(); return Ok(()); } @@ -589,14 +613,9 @@ impl PlaybackModeManager { session_id: session_id.to_string(), }); - // Enable remote volume control on Android (intercepts volume buttons) - #[cfg(target_os = "android")] - { - if let Err(e) = crate::player::enable_remote_volume(50) { - log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e); - // Non-fatal - continue with transfer - } - } + // Start the service + remote-volume control (intercepts volume buttons, + // and starts the foreground service that renders the lockscreen card). + self.enable_remote_control(); log::info!("[PlaybackMode] Successfully transferred to remote"); Ok(()) diff --git a/src/lib/components/BottomUi.svelte b/src/lib/components/BottomUi.svelte new file mode 100644 index 00000000..21e3ae0a --- /dev/null +++ b/src/lib/components/BottomUi.svelte @@ -0,0 +1,64 @@ + + + + +
+ {#if showMiniPlayer} + showSleepTimerModal.set(true)} + /> + {/if} + + {#if showNav} + + {/if} +
diff --git a/src/lib/stores/appState.ts b/src/lib/stores/appState.ts index 37fdb0f8..368c42a0 100644 --- a/src/lib/stores/appState.ts +++ b/src/lib/stores/appState.ts @@ -9,10 +9,6 @@ export const isAndroid = writable(false); // Shuffle/repeat/next/previous state now lives in the event-driven queue store // ($lib/stores/queue), the single source of truth. export const showSleepTimerModal = writable(false); -// Measured height (px) of the fixed bottom UI on Android: BottomNav stacked with -// the global mini player. Published by the root layout via ResizeObserver so the -// library list can reserve exactly that much bottom padding (no magic rem guesses). -export const bottomUiHeight = writable(0); // Library-specific state export const librarySearchQuery = writable(""); diff --git a/src/lib/stores/playbackMode.test.ts b/src/lib/stores/playbackMode.test.ts index c5d2e2a1..f86510e5 100644 --- a/src/lib/stores/playbackMode.test.ts +++ b/src/lib/stores/playbackMode.test.ts @@ -26,6 +26,18 @@ vi.mock("./sessions", () => ({ }, })); +// Capture the playerStatusEvent listener so tests can drive backend +// `playback_mode_changed` events through the reconciler. The commands still flow +// to the real bindings (which call the mocked `invoke`), so the existing +// refresh/transfer tests keep exercising the true command path. +let capturedStatusListener: ((event: { payload: any }) => void) | null = null; +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn((_name: string, cb: (event: { payload: any }) => void) => { + capturedStatusListener = cb; + return Promise.resolve(() => {}); + }), +})); + // Mock auth store const mockGetHandle = vi.fn(() => "repo-handle-1"); vi.mock("./auth", () => ({ @@ -42,6 +54,7 @@ describe("playbackMode store", () => { beforeEach(() => { vi.clearAllMocks(); currentSelectedSession = null; + capturedStatusListener = null; }); afterEach(() => { @@ -327,6 +340,56 @@ describe("playbackMode store", () => { }); }); + describe("backend playback_mode_changed reconciler", () => { + // Regression: commit 2a1f168 made Rust re-broadcast PlaybackModeChanged on + // every set_mode. Local playback drives set_mode("local") from both the + // frontend and Rust, so the same mode arrives repeatedly. The reconciler + // used to run selectSession(null) on each one, deselecting the remote + // session mid-cast and tripping the disconnect watchdog — which broke the + // lockscreen card, remote volume, and (via the mode flap) local audio. + async function initListener() { + const { playbackMode } = await import("./playbackMode"); + playbackMode.initializeSessionMonitoring(); + expect(capturedStatusListener).not.toBeNull(); + return playbackMode; + } + + it("ignores a no-op remote re-broadcast (no session churn)", async () => { + currentSelectedSession = { id: "sess-1" }; + const playbackMode = await initListener(); + playbackMode.setMode("remote", "sess-1"); + mockSelectSession.mockClear(); + + // Rust re-broadcasts the SAME remote mode (e.g. a position tick path). + capturedStatusListener!({ + payload: { type: "playback_mode_changed", mode: "remote", session_id: "sess-1" }, + }); + + const state = get(playbackMode); + expect(state.mode).toBe("remote"); + expect(state.remoteSessionId).toBe("sess-1"); + // Must NOT re-select (which would churn the watchdog) on a no-op. + expect(mockSelectSession).not.toHaveBeenCalled(); + }); + + it("adopts a genuine remote→local change and clears the session", async () => { + currentSelectedSession = { id: "sess-1" }; + const playbackMode = await initListener(); + playbackMode.setMode("remote", "sess-1"); + mockSelectSession.mockClear(); + + capturedStatusListener!({ + payload: { type: "playback_mode_changed", mode: "local", session_id: null }, + }); + + const state = get(playbackMode); + expect(state.mode).toBe("local"); + expect(state.remoteSessionId).toBeNull(); + expect(mockSelectSession).toHaveBeenCalledWith(null); + }); + + }); + describe("transfer reconciles to Rust on completion", () => { it("refreshes from Rust after a successful transferToRemote", async () => { const { playbackMode } = await import("./playbackMode"); diff --git a/src/lib/stores/playbackMode.ts b/src/lib/stores/playbackMode.ts index 270c6842..78a7c1e2 100644 --- a/src/lib/stores/playbackMode.ts +++ b/src/lib/stores/playbackMode.ts @@ -316,10 +316,31 @@ function createPlaybackModeStore() { const mode = event.payload.mode as PlaybackMode; const remoteSessionId = mode === "remote" ? event.payload.session_id ?? null : null; + + // Ignore no-op re-broadcasts. The backend re-emits on every set_mode, and + // local playback drives set_mode("local") from BOTH the frontend + // (handleStateChanged) and Rust, so the same mode arrives repeatedly. If + // we reconciled unconditionally we'd re-run selectSession(null) on each + // one, deselecting the remote session mid-cast and tripping the + // disconnect-to-idle watchdog (breaking the lockscreen card, remote + // volume, and — via the resulting mode flap — local audio). + if ( + currentState.mode === mode && + currentState.remoteSessionId === remoteSessionId + ) { + return; + } + console.log("[PlaybackMode] Backend mode changed →", mode, remoteSessionId); update((s) => ({ ...s, mode, remoteSessionId })); - // Keep the selected session in step so the merged UI stores follow. - sessions.selectSession(remoteSessionId); + // Keep the selected session in step so the merged UI stores follow, but + // only touch the selection when it actually differs — re-selecting the + // same id (or clearing on a non-remote emit that isn't a real change) + // would needlessly churn the session watchdog. + const selected = get(selectedSession); + if ((selected?.id ?? null) !== remoteSessionId) { + sessions.selectSession(remoteSessionId); + } } }); diff --git a/src/lib/utils/layoutShell.test.ts b/src/lib/utils/layoutShell.test.ts index 17f575e8..89453ea1 100644 --- a/src/lib/utils/layoutShell.test.ts +++ b/src/lib/utils/layoutShell.test.ts @@ -1,13 +1,15 @@ /** - * Regression tests for the app's fixed bottom-UI (mini player + bottom nav) - * layout rules. + * Tests for the app's bottom-UI (mini player + bottom nav) visibility rules. * - * The bug these guard against: on the library route the layout used to render - * its OWN in-flow mini player while the root ALSO painted a fixed bottom nav on - * top of it, and the library scroller only reserved 1rem — so the last row hid - * behind the nav. The fix unified everything onto the root: the root owns the - * single fixed bottom UI on every route/platform, and every scroll container - * reserves the measured `bottomUiHeight`. + * The overlap bug these guard against: on the library page the last rows were + * hidden behind the bottom nav. It was caused by rendering the bottom UI as a + * FIXED overlay and trying to reserve its (async-measured, initially-0) height + * as padding. The fix renders the bottom UI as an in-flow flex child below the + * scroller, so overlap is structurally impossible — no measurement, no padding. + * + * These pure functions only decide *whether* each piece shows on a route. The + * structural guarantee (flex sibling below the scroller) is exercised by + * running the app, not by jsdom (which has no layout engine). * * TRACES: UR-005 | DR-009 */ @@ -18,7 +20,6 @@ import { showGlobalMiniPlayer, routeOwnsLayout, showBottomUi, - reservedBottomPadding, } from "./layoutShell"; const authed = (pathname: string) => ({ pathname, isAuthenticated: true }); @@ -44,15 +45,14 @@ describe("showGlobalMiniPlayer", () => { expect(showGlobalMiniPlayer({ pathname: "/settings" })).toBe(false); }); - it("does NOT depend on platform or on /library — the root owns it everywhere", () => { - // The signature intentionally has no `isAndroid` input: the old bug was a - // platform/route split that let a second in-flow mini player exist. + it("does NOT depend on platform or on /library — one code path everywhere", () => { + // The old bug was a platform/route split that let a second mini player exist. expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true); }); }); describe("showBottomNav", () => { - it("shows on authenticated content routes including library", () => { + it("shows on authenticated content routes including library and settings", () => { expect(showBottomNav(authed("/library"))).toBe(true); expect(showBottomNav(authed("/"))).toBe(true); expect(showBottomNav(authed("/settings"))).toBe(true); @@ -69,64 +69,55 @@ describe("showBottomNav", () => { }); describe("routeOwnsLayout", () => { - it("is true for library/settings/player/login (they manage their own scroll)", () => { + it("is true for library/player/login (they render their own flex column + BottomUi)", () => { expect(routeOwnsLayout({ pathname: "/library" })).toBe(true); expect(routeOwnsLayout({ pathname: "/library/abc" })).toBe(true); - expect(routeOwnsLayout({ pathname: "/settings" })).toBe(true); expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true); expect(routeOwnsLayout({ pathname: "/login" })).toBe(true); }); - it("is false for routes that render into the root scroller", () => { + it("is false for routes that render into the root scroller (incl. settings)", () => { + // Settings has no +layout of its own; it must flow through the root scroller + // so the root's in-flow BottomUi renders below it (otherwise settings loses + // its nav, since the fixed-overlay nav no longer exists). expect(routeOwnsLayout({ pathname: "/" })).toBe(false); expect(routeOwnsLayout({ pathname: "/search" })).toBe(false); expect(routeOwnsLayout({ pathname: "/downloads" })).toBe(false); + expect(routeOwnsLayout({ pathname: "/settings" })).toBe(false); }); }); -describe("layout invariant: a reservation owner exists wherever bottom UI shows", () => { - // The core anti-regression check. Every route falls into exactly one of two - // reservation regimes: - // - route owns its layout -> the route's own scroller reserves bottomUiHeight - // - route does NOT own it -> the root scroller reserves bottomUiHeight - // The bug was that the library route was implicitly a THIRD regime: it owned - // its layout, showed a fixed nav from the root, but reserved only 1rem. That - // can't recur now because library both owns its layout (so it reserves - // internally) and the mini player is root-owned (no second in-flow bar). +describe("structural invariant: every route that shows bottom UI has a scroller above it", () => { + // With the in-flow model, "the bottom UI is a flex sibling below a scroller" + // must hold on every route where it shows. That scroller is provided by + // exactly one owner: + // - routeOwnsLayout === true -> the route's own column (header + main + BottomUi) + // - routeOwnsLayout === false -> the root column (scroller + BottomUi) + // The forbidden state — bottom UI shows but no owning column renders a + // scroller + BottomUi pair — cannot occur because the two branches are total. const routes = ["/", "/search", "/downloads", "/library", "/library/abc", "/settings"]; for (const pathname of routes) { - it(`${pathname}: exactly one reservation owner`, () => { - if (!showBottomUi(authed(pathname))) return; // no bottom UI -> nothing to reserve - // Ownership is a total boolean, so exactly one regime always applies — - // there is no route that shows bottom UI with no reservation owner. + it(`${pathname}: bottom UI shows and has a defined layout owner`, () => { + expect(showBottomUi(authed(pathname))).toBe(true); expect(typeof routeOwnsLayout({ pathname })).toBe("boolean"); }); } - it("library shows bottom UI AND owns its layout, so it reserves internally", () => { - // Directly pins the regression: library must NOT rely on the root scroller - // (it has none — the root gives owning routes a clipped, non-scrolling box). + it("library owns its layout, so it renders its own in-flow BottomUi", () => { + // Directly pins the original regression: library must render BottomUi inside + // its own column (the root gives owning routes a clipped, non-scrolling box). expect(showBottomUi(authed("/library"))).toBe(true); expect(routeOwnsLayout({ pathname: "/library" })).toBe(true); }); -}); -describe("reservedBottomPadding", () => { - it("returns an exact px fit when no extra room requested", () => { - expect(reservedBottomPadding(120)).toBe("120px"); + it("settings does NOT own its layout, so the root scroller + BottomUi cover it", () => { + expect(showBottomUi(authed("/settings"))).toBe(true); + expect(routeOwnsLayout({ pathname: "/settings" })).toBe(false); }); - it("adds breathing room via calc for layout-owning routes", () => { - expect(reservedBottomPadding(120, 1)).toBe("calc(120px + 1rem)"); - }); - - it("never returns negative padding", () => { - expect(reservedBottomPadding(-50)).toBe("0px"); - expect(reservedBottomPadding(-50, 1)).toBe("calc(0px + 1rem)"); - }); - - it("reserves 1rem-only when the bottom UI is collapsed to 0 (nothing playing, nav-only measured elsewhere)", () => { - expect(reservedBottomPadding(0, 1)).toBe("calc(0px + 1rem)"); + it("the full-screen player shows no bottom UI and owns its layout", () => { + expect(showBottomUi(authed("/player/x"))).toBe(false); + expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true); }); }); diff --git a/src/lib/utils/layoutShell.ts b/src/lib/utils/layoutShell.ts index 2d89d1cd..e98aa00e 100644 --- a/src/lib/utils/layoutShell.ts +++ b/src/lib/utils/layoutShell.ts @@ -1,20 +1,18 @@ /** - * Pure layout-shell logic for the app's fixed bottom UI (mini player stacked - * over the bottom nav). + * Pure layout-shell visibility rules for the app's bottom UI (mini player + * stacked over the bottom nav). * * These rules used to live as inline `$derived` booleans scattered across the - * root and library `+layout.svelte` files, and diverged per platform/route — - * which is exactly how the "last row hidden behind the nav" bug kept coming - * back. The invariant is now a single source of truth: + * root and library `+layout.svelte` files and diverged per platform/route. * - * - The ROOT layout owns the single fixed bottom UI on every route/platform. - * There is no per-route/per-platform second mini player. - * - Whatever fixed bottom UI is showing has a live-measured height - * (`bottomUiHeight`), and every scroll container reserves exactly that much - * bottom space so the last row can never render behind the nav. + * The overlap bug ("last row hidden behind the nav") is now solved + * STRUCTURALLY, not by these rules: the bottom UI is rendered as an in-flow + * flex child below the scroller (see BottomUi.svelte), so the scroller is + * physically bounded above it and can never render behind it. There is no + * measurement and no reserved padding. These functions only decide *whether* + * each piece is visible on a given route. * - * Keeping this pure makes the invariant unit-testable (jsdom has no layout - * engine, so the geometry itself can't be tested — but the decision logic can). + * Keeping them pure makes the visibility contract unit-testable. * * TRACES: UR-005 | DR-009 */ @@ -56,41 +54,24 @@ export function showGlobalMiniPlayer({ pathname }: { pathname: string }): boolea } /** - * Routes that own their own full-height layout (their own scroll container + - * bottom-space reservation). The root leaves these as a plain non-scrolling box - * and does NOT add bottom padding — the route reserves `bottomUiHeight` itself. - * Every other route scrolls in the root wrapper, which reserves the space. + * Routes that render their own full-height flex column (header + scroller + + * their own in-flow BottomUi). The root leaves these as a plain clipped box and + * does not render its own BottomUi. Every other route renders into the root's + * scroller, with the root's in-flow BottomUi as a flex sibling below it. */ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean { return ( pathname.startsWith("/library") || - pathname.startsWith("/settings") || pathname.startsWith("/player/") || pathname.startsWith("/login") ); } /** - * Whether any fixed bottom UI is showing for this route (mini player, nav, or - * both). When true, the active scroll container must reserve `bottomUiHeight`. + * Whether any bottom UI is showing for this route (mini player, nav, or both). + * The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte), + * so this is purely a visibility question — there is no padding to reserve. */ export function showBottomUi(input: BottomUiVisibilityInput): boolean { return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname }); } - -/** - * The bottom padding (in CSS) a scroll container must reserve so its last row - * clears the fixed bottom UI. `bottomUiHeight` is the live-measured height of - * the root's fixed bottom UI wrapper. - * - * @param bottomUiHeight measured height in px of the fixed bottom UI (0 if none) - * @param extraRem breathing room added on top (routes that own their - * layout add 1rem; the root wrapper reserves an exact fit) - */ -export function reservedBottomPadding( - bottomUiHeight: number, - extraRem = 0, -): string { - const px = Math.max(0, bottomUiHeight); - return extraRem > 0 ? `calc(${px}px + ${extraRem}rem)` : `${px}px`; -} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1a5fb2bb..7665677a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,7 +2,6 @@ import { onMount, onDestroy } from "svelte"; import { get } from "svelte/store"; import { page } from "$app/stores"; - import { goto } from "$app/navigation"; import { platform } from "@tauri-apps/plugin-os"; import "../app.css"; import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth"; @@ -13,72 +12,39 @@ import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog"; import { playbackMode } from "$lib/stores/playbackMode"; import { sessions } from "$lib/stores/sessions"; - import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player"; import ReauthModal from "$lib/components/auth/ReauthModal.svelte"; import Toast from "$lib/components/Toast.svelte"; - import MiniPlayer from "$lib/components/player/MiniPlayer.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; - import BottomNav from "$lib/components/BottomNav.svelte"; - import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal, bottomUiHeight } from "$lib/stores/appState"; + import BottomUi from "$lib/components/BottomUi.svelte"; + import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState"; import { showBottomNav as computeShowBottomNav, showGlobalMiniPlayer as computeShowGlobalMiniPlayer, routeOwnsLayout as computeRouteOwnsLayout, - showBottomUi as computeShowBottomUi, - reservedBottomPadding, } from "$lib/utils/layoutShell"; - // Shuffle/repeat/next/previous come from the event-driven queue store, the - // single source of truth (updated instantly on queue_changed). - import { isShuffle as shuffle, repeatMode as repeat, hasNext, hasPrevious } from "$lib/stores/queue"; let { children } = $props(); - // The fixed bottom UI (mini player stacked over the bottom nav) is measured in - // real time and its height published to `bottomUiHeight`, so pages can reserve - // exactly that much space instead of guessing fixed rem values. - let bottomUiEl = $state(null); - - // Route-level visibility for the fixed bottom UI (the mini player itself also - // self-gates on playback state; when it renders nothing the in-flow slot - // collapses to 0 and the ResizeObserver shrinks the reserved padding). - // All layout-shell visibility/reservation rules live in one pure, unit-tested - // module ($lib/utils/layoutShell) so they can't drift per route/platform. - // The root owns the single fixed bottom UI (mini player + nav) on every route; - // the library route used to render its own in-flow mini player, which double- - // stacked with this fixed one and hid the last row behind the nav. + // Layout-shell visibility rules live in one pure, unit-tested module + // ($lib/utils/layoutShell) so they can't drift per route/platform. + // + // The bottom UI (mini player + nav) is rendered IN FLEX FLOW below the + // scroller — never as a fixed overlay — so the list is physically bounded + // above it and cannot render behind it. There is nothing to measure or + // reserve; the old ResizeObserver/`bottomUiHeight`/padding scheme (which + // started at 0 and kept regressing into "last row hidden behind the nav") is + // gone. See BottomUi.svelte. const pathname = $derived($page.url.pathname); const showBottomNav = $derived( computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated }) ); const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname })); - // The library and settings routes own their own full-height layout (their own - // scroll container + bottom-space reservation), so the root must leave their - // wrapper as a plain non-scrolling box. Every other top-level page (search, - // downloads, sessions, home) renders straight into the root, so the root - // wrapper has to scroll AND reserve the fixed bottom UI's height — otherwise - // the mini player / bottom nav overlay the last rows of content. + // Library/settings/player/login own their own full-height flex column + // (header + scroller + their own in-flow BottomUi), so the root just clips + // and lets them manage layout. Every other route renders into the root's + // scroller, with the root's in-flow BottomUi as a flex sibling below it. const routeOwnsLayout = $derived(computeRouteOwnsLayout({ pathname })); - const showBottomUi = $derived( - computeShowBottomUi({ pathname, isAuthenticated: $isAuthenticated }) - ); - - $effect(() => { - const el = bottomUiEl; - if (!el) { - bottomUiHeight.set(0); - return; - } - const ro = new ResizeObserver((entries) => { - bottomUiHeight.set(entries[0]?.contentRect.height ?? el.offsetHeight); - }); - ro.observe(el); - bottomUiHeight.set(el.offsetHeight); - return () => { - ro.disconnect(); - bottomUiHeight.set(0); - }; - }); onMount(async () => { // Detect platform first (synchronously, before any await) so the global @@ -210,13 +176,18 @@ this wrapper must scroll and reserve the fixed bottom UI's measured height so the mini player / bottom nav never overlap the last rows. --> {#if routeOwnsLayout} +
{@render children()}
{:else} +
{@render children()}
@@ -228,44 +199,17 @@ - - {#if showBottomNav || showGlobalMiniPlayer} -
- {#if showGlobalMiniPlayer} - { - // Navigate to player page when mini player is expanded - if ($currentMedia) { - goto(`/player/${$currentMedia.id}`); - } - }} - onSleepTimerClick={() => showSleepTimerModal.set(true)} - /> - {/if} - - {#if showBottomNav} - - {/if} -
- - - showSleepTimerModal.set(false)} - /> + + {#if !routeOwnsLayout && (showBottomNav || showGlobalMiniPlayer)} + {/if} + + + showSleepTimerModal.set(false)} + /> {:else}
diff --git a/src/routes/library/+layout.svelte b/src/routes/library/+layout.svelte index f4833f75..ed8507f2 100644 --- a/src/routes/library/+layout.svelte +++ b/src/routes/library/+layout.svelte @@ -5,10 +5,9 @@ import { commands } from "$lib/api/bindings"; import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth"; import { library } from "$lib/stores/library"; - import { bottomUiHeight } from "$lib/stores/appState"; - import { reservedBottomPadding } from "$lib/utils/layoutShell"; import { useScrollGuard } from "$lib/composables/useScrollGuard"; import Search from "$lib/components/Search.svelte"; + import BottomUi from "$lib/components/BottomUi.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; // Scroll guard prevents accidental taps on library cards during/after scrolling (Android) @@ -190,18 +189,20 @@
- +
{@render children()}
+ + + -
+

Audio Settings

Configure playback and audio processing