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