Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1992a8187d |
@@ -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
|
/// Check if currently transferring
|
||||||
pub fn is_transferring(&self) -> bool {
|
pub fn is_transferring(&self) -> bool {
|
||||||
self.is_transferring.load(Ordering::Relaxed)
|
self.is_transferring.load(Ordering::Relaxed)
|
||||||
@@ -334,6 +354,10 @@ impl PlaybackModeManager {
|
|||||||
self.set_mode(PlaybackMode::Remote {
|
self.set_mode(PlaybackMode::Remote {
|
||||||
session_id: session_id.to_string(),
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,14 +613,9 @@ impl PlaybackModeManager {
|
|||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enable remote volume control on Android (intercepts volume buttons)
|
// Start the service + remote-volume control (intercepts volume buttons,
|
||||||
#[cfg(target_os = "android")]
|
// and starts the foreground service that renders the lockscreen card).
|
||||||
{
|
self.enable_remote_control();
|
||||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
|
||||||
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
|
|
||||||
// Non-fatal - continue with transfer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[PlaybackMode] Successfully transferred to remote");
|
log::info!("[PlaybackMode] Successfully transferred to remote");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!--
|
||||||
|
BottomUi — the app's bottom UI (mini player stacked over the bottom nav).
|
||||||
|
|
||||||
|
Rendered as an IN-FLOW flex child at the bottom of a full-height flex column,
|
||||||
|
NOT a fixed overlay. This is the whole point: because it is a normal flex
|
||||||
|
sibling below the scroll container (which is `flex-1 min-h-0 overflow-y-auto`),
|
||||||
|
the scroller is physically bounded above it and can never render behind it.
|
||||||
|
|
||||||
|
This replaces the old ResizeObserver + `bottomUiHeight` + padding-reservation
|
||||||
|
scheme, which started at 0, updated async, and repeatedly regressed into the
|
||||||
|
"last row hidden behind the nav" bug. There is nothing to measure or reserve:
|
||||||
|
the browser's flex layout does it exactly, every frame.
|
||||||
|
|
||||||
|
The Android system gesture bar is cleared via `env(safe-area-inset-bottom)`.
|
||||||
|
|
||||||
|
TRACES: UR-005 | DR-009
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
|
||||||
|
import { isShuffle, repeatMode, hasNext, hasPrevious } from "$lib/stores/queue";
|
||||||
|
import { showSleepTimerModal } from "$lib/stores/appState";
|
||||||
|
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
||||||
|
import BottomNav from "$lib/components/BottomNav.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
showMiniPlayer = true,
|
||||||
|
showNav = true,
|
||||||
|
onExpand,
|
||||||
|
}: {
|
||||||
|
showMiniPlayer?: boolean;
|
||||||
|
showNav?: boolean;
|
||||||
|
// Where "expand mini player" goes. Defaults to the full player route.
|
||||||
|
onExpand?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function expand() {
|
||||||
|
if (onExpand) return onExpand();
|
||||||
|
if ($currentMedia) goto(`/player/${$currentMedia.id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- flex-shrink-0 so it keeps its natural height; the scroller sibling flexes. -->
|
||||||
|
<div class="flex-shrink-0 pb-[env(safe-area-inset-bottom)] bg-[var(--color-surface)]">
|
||||||
|
{#if showMiniPlayer}
|
||||||
|
<MiniPlayer
|
||||||
|
media={$currentMedia}
|
||||||
|
isPlaying={$isPlaying}
|
||||||
|
position={$playbackPosition}
|
||||||
|
duration={$playbackDuration}
|
||||||
|
shuffle={$isShuffle}
|
||||||
|
repeat={$repeatMode}
|
||||||
|
hasNext={$hasNext}
|
||||||
|
hasPrevious={$hasPrevious}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
onExpand={expand}
|
||||||
|
onSleepTimerClick={() => showSleepTimerModal.set(true)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showNav}
|
||||||
|
<BottomNav className="flex-shrink-0" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -9,10 +9,6 @@ export const isAndroid = writable(false);
|
|||||||
// Shuffle/repeat/next/previous state now lives in the event-driven queue store
|
// Shuffle/repeat/next/previous state now lives in the event-driven queue store
|
||||||
// ($lib/stores/queue), the single source of truth.
|
// ($lib/stores/queue), the single source of truth.
|
||||||
export const showSleepTimerModal = writable(false);
|
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
|
// Library-specific state
|
||||||
export const librarySearchQuery = writable("");
|
export const librarySearchQuery = writable("");
|
||||||
|
|||||||
@@ -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
|
// Mock auth store
|
||||||
const mockGetHandle = vi.fn(() => "repo-handle-1");
|
const mockGetHandle = vi.fn(() => "repo-handle-1");
|
||||||
vi.mock("./auth", () => ({
|
vi.mock("./auth", () => ({
|
||||||
@@ -42,6 +54,7 @@ describe("playbackMode store", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
currentSelectedSession = null;
|
currentSelectedSession = null;
|
||||||
|
capturedStatusListener = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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", () => {
|
describe("transfer reconciles to Rust on completion", () => {
|
||||||
it("refreshes from Rust after a successful transferToRemote", async () => {
|
it("refreshes from Rust after a successful transferToRemote", async () => {
|
||||||
const { playbackMode } = await import("./playbackMode");
|
const { playbackMode } = await import("./playbackMode");
|
||||||
|
|||||||
@@ -316,11 +316,32 @@ function createPlaybackModeStore() {
|
|||||||
const mode = event.payload.mode as PlaybackMode;
|
const mode = event.payload.mode as PlaybackMode;
|
||||||
const remoteSessionId =
|
const remoteSessionId =
|
||||||
mode === "remote" ? event.payload.session_id ?? null : null;
|
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);
|
console.log("[PlaybackMode] Backend mode changed →", mode, remoteSessionId);
|
||||||
update((s) => ({ ...s, mode, remoteSessionId }));
|
update((s) => ({ ...s, mode, remoteSessionId }));
|
||||||
// Keep the selected session in step so the merged UI stores follow.
|
// 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);
|
sessions.selectSession(remoteSessionId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
selectedSession.subscribe((session) => {
|
selectedSession.subscribe((session) => {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Regression tests for the app's fixed bottom-UI (mini player + bottom nav)
|
* Tests for the app's bottom-UI (mini player + bottom nav) visibility rules.
|
||||||
* layout rules.
|
|
||||||
*
|
*
|
||||||
* The bug these guard against: on the library route the layout used to render
|
* The overlap bug these guard against: on the library page the last rows were
|
||||||
* its OWN in-flow mini player while the root ALSO painted a fixed bottom nav on
|
* hidden behind the bottom nav. It was caused by rendering the bottom UI as a
|
||||||
* top of it, and the library scroller only reserved 1rem — so the last row hid
|
* FIXED overlay and trying to reserve its (async-measured, initially-0) height
|
||||||
* behind the nav. The fix unified everything onto the root: the root owns the
|
* as padding. The fix renders the bottom UI as an in-flow flex child below the
|
||||||
* single fixed bottom UI on every route/platform, and every scroll container
|
* scroller, so overlap is structurally impossible — no measurement, no padding.
|
||||||
* reserves the measured `bottomUiHeight`.
|
*
|
||||||
|
* 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
|
* TRACES: UR-005 | DR-009
|
||||||
*/
|
*/
|
||||||
@@ -18,7 +20,6 @@ import {
|
|||||||
showGlobalMiniPlayer,
|
showGlobalMiniPlayer,
|
||||||
routeOwnsLayout,
|
routeOwnsLayout,
|
||||||
showBottomUi,
|
showBottomUi,
|
||||||
reservedBottomPadding,
|
|
||||||
} from "./layoutShell";
|
} from "./layoutShell";
|
||||||
|
|
||||||
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
|
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
|
||||||
@@ -44,15 +45,14 @@ describe("showGlobalMiniPlayer", () => {
|
|||||||
expect(showGlobalMiniPlayer({ pathname: "/settings" })).toBe(false);
|
expect(showGlobalMiniPlayer({ pathname: "/settings" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT depend on platform or on /library — the root owns it everywhere", () => {
|
it("does NOT depend on platform or on /library — one code path everywhere", () => {
|
||||||
// The signature intentionally has no `isAndroid` input: the old bug was a
|
// The old bug was a platform/route split that let a second mini player exist.
|
||||||
// platform/route split that let a second in-flow mini player exist.
|
|
||||||
expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true);
|
expect(showGlobalMiniPlayer({ pathname: "/library" })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("showBottomNav", () => {
|
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("/library"))).toBe(true);
|
||||||
expect(showBottomNav(authed("/"))).toBe(true);
|
expect(showBottomNav(authed("/"))).toBe(true);
|
||||||
expect(showBottomNav(authed("/settings"))).toBe(true);
|
expect(showBottomNav(authed("/settings"))).toBe(true);
|
||||||
@@ -69,64 +69,55 @@ describe("showBottomNav", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("routeOwnsLayout", () => {
|
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" })).toBe(true);
|
||||||
expect(routeOwnsLayout({ pathname: "/library/abc" })).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: "/player/x" })).toBe(true);
|
||||||
expect(routeOwnsLayout({ pathname: "/login" })).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: "/" })).toBe(false);
|
||||||
expect(routeOwnsLayout({ pathname: "/search" })).toBe(false);
|
expect(routeOwnsLayout({ pathname: "/search" })).toBe(false);
|
||||||
expect(routeOwnsLayout({ pathname: "/downloads" })).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", () => {
|
describe("structural invariant: every route that shows bottom UI has a scroller above it", () => {
|
||||||
// The core anti-regression check. Every route falls into exactly one of two
|
// With the in-flow model, "the bottom UI is a flex sibling below a scroller"
|
||||||
// reservation regimes:
|
// must hold on every route where it shows. That scroller is provided by
|
||||||
// - route owns its layout -> the route's own scroller reserves bottomUiHeight
|
// exactly one owner:
|
||||||
// - route does NOT own it -> the root scroller reserves bottomUiHeight
|
// - routeOwnsLayout === true -> the route's own column (header + main + BottomUi)
|
||||||
// The bug was that the library route was implicitly a THIRD regime: it owned
|
// - routeOwnsLayout === false -> the root column (scroller + BottomUi)
|
||||||
// its layout, showed a fixed nav from the root, but reserved only 1rem. That
|
// The forbidden state — bottom UI shows but no owning column renders a
|
||||||
// can't recur now because library both owns its layout (so it reserves
|
// scroller + BottomUi pair — cannot occur because the two branches are total.
|
||||||
// internally) and the mini player is root-owned (no second in-flow bar).
|
|
||||||
const routes = ["/", "/search", "/downloads", "/library", "/library/abc", "/settings"];
|
const routes = ["/", "/search", "/downloads", "/library", "/library/abc", "/settings"];
|
||||||
|
|
||||||
for (const pathname of routes) {
|
for (const pathname of routes) {
|
||||||
it(`${pathname}: exactly one reservation owner`, () => {
|
it(`${pathname}: bottom UI shows and has a defined layout owner`, () => {
|
||||||
if (!showBottomUi(authed(pathname))) return; // no bottom UI -> nothing to reserve
|
expect(showBottomUi(authed(pathname))).toBe(true);
|
||||||
// Ownership is a total boolean, so exactly one regime always applies —
|
|
||||||
// there is no route that shows bottom UI with no reservation owner.
|
|
||||||
expect(typeof routeOwnsLayout({ pathname })).toBe("boolean");
|
expect(typeof routeOwnsLayout({ pathname })).toBe("boolean");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it("library shows bottom UI AND owns its layout, so it reserves internally", () => {
|
it("library owns its layout, so it renders its own in-flow BottomUi", () => {
|
||||||
// Directly pins the regression: library must NOT rely on the root scroller
|
// Directly pins the original regression: library must render BottomUi inside
|
||||||
// (it has none — the root gives owning routes a clipped, non-scrolling box).
|
// its own column (the root gives owning routes a clipped, non-scrolling box).
|
||||||
expect(showBottomUi(authed("/library"))).toBe(true);
|
expect(showBottomUi(authed("/library"))).toBe(true);
|
||||||
expect(routeOwnsLayout({ pathname: "/library" })).toBe(true);
|
expect(routeOwnsLayout({ pathname: "/library" })).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("reservedBottomPadding", () => {
|
it("settings does NOT own its layout, so the root scroller + BottomUi cover it", () => {
|
||||||
it("returns an exact px fit when no extra room requested", () => {
|
expect(showBottomUi(authed("/settings"))).toBe(true);
|
||||||
expect(reservedBottomPadding(120)).toBe("120px");
|
expect(routeOwnsLayout({ pathname: "/settings" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds breathing room via calc for layout-owning routes", () => {
|
it("the full-screen player shows no bottom UI and owns its layout", () => {
|
||||||
expect(reservedBottomPadding(120, 1)).toBe("calc(120px + 1rem)");
|
expect(showBottomUi(authed("/player/x"))).toBe(false);
|
||||||
});
|
expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true);
|
||||||
|
|
||||||
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)");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Pure layout-shell logic for the app's fixed bottom UI (mini player stacked
|
* Pure layout-shell visibility rules for the app's bottom UI (mini player
|
||||||
* over the bottom nav).
|
* stacked over the bottom nav).
|
||||||
*
|
*
|
||||||
* These rules used to live as inline `$derived` booleans scattered across the
|
* These rules used to live as inline `$derived` booleans scattered across the
|
||||||
* root and library `+layout.svelte` files, and diverged per platform/route —
|
* 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:
|
|
||||||
*
|
*
|
||||||
* - The ROOT layout owns the single fixed bottom UI on every route/platform.
|
* The overlap bug ("last row hidden behind the nav") is now solved
|
||||||
* There is no per-route/per-platform second mini player.
|
* STRUCTURALLY, not by these rules: the bottom UI is rendered as an in-flow
|
||||||
* - Whatever fixed bottom UI is showing has a live-measured height
|
* flex child below the scroller (see BottomUi.svelte), so the scroller is
|
||||||
* (`bottomUiHeight`), and every scroll container reserves exactly that much
|
* physically bounded above it and can never render behind it. There is no
|
||||||
* bottom space so the last row can never render behind the nav.
|
* 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
|
* Keeping them pure makes the visibility contract unit-testable.
|
||||||
* engine, so the geometry itself can't be tested — but the decision logic can).
|
|
||||||
*
|
*
|
||||||
* TRACES: UR-005 | DR-009
|
* 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 +
|
* Routes that render their own full-height flex column (header + scroller +
|
||||||
* bottom-space reservation). The root leaves these as a plain non-scrolling box
|
* their own in-flow BottomUi). The root leaves these as a plain clipped box and
|
||||||
* and does NOT add bottom padding — the route reserves `bottomUiHeight` itself.
|
* does not render its own BottomUi. Every other route renders into the root's
|
||||||
* Every other route scrolls in the root wrapper, which reserves the space.
|
* scroller, with the root's in-flow BottomUi as a flex sibling below it.
|
||||||
*/
|
*/
|
||||||
export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
|
||||||
return (
|
return (
|
||||||
pathname.startsWith("/library") ||
|
pathname.startsWith("/library") ||
|
||||||
pathname.startsWith("/settings") ||
|
|
||||||
pathname.startsWith("/player/") ||
|
pathname.startsWith("/player/") ||
|
||||||
pathname.startsWith("/login")
|
pathname.startsWith("/login")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether any fixed bottom UI is showing for this route (mini player, nav, or
|
* Whether any bottom UI is showing for this route (mini player, nav, or both).
|
||||||
* both). When true, the active scroll container must reserve `bottomUiHeight`.
|
* 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 {
|
export function showBottomUi(input: BottomUiVisibilityInput): boolean {
|
||||||
return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname });
|
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`;
|
|
||||||
}
|
|
||||||
|
|||||||
+26
-82
@@ -2,7 +2,6 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import "../app.css";
|
import "../app.css";
|
||||||
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
|
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 { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
|
||||||
import { playbackMode } from "$lib/stores/playbackMode";
|
import { playbackMode } from "$lib/stores/playbackMode";
|
||||||
import { sessions } from "$lib/stores/sessions";
|
import { sessions } from "$lib/stores/sessions";
|
||||||
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
|
|
||||||
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
|
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
|
||||||
import Toast from "$lib/components/Toast.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 SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||||
import BottomNav from "$lib/components/BottomNav.svelte";
|
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||||
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal, bottomUiHeight } from "$lib/stores/appState";
|
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
|
||||||
import {
|
import {
|
||||||
showBottomNav as computeShowBottomNav,
|
showBottomNav as computeShowBottomNav,
|
||||||
showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
|
showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
|
||||||
routeOwnsLayout as computeRouteOwnsLayout,
|
routeOwnsLayout as computeRouteOwnsLayout,
|
||||||
showBottomUi as computeShowBottomUi,
|
|
||||||
reservedBottomPadding,
|
|
||||||
} from "$lib/utils/layoutShell";
|
} 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();
|
let { children } = $props();
|
||||||
|
|
||||||
// The fixed bottom UI (mini player stacked over the bottom nav) is measured in
|
// Layout-shell visibility rules live in one pure, unit-tested module
|
||||||
// real time and its height published to `bottomUiHeight`, so pages can reserve
|
// ($lib/utils/layoutShell) so they can't drift per route/platform.
|
||||||
// exactly that much space instead of guessing fixed rem values.
|
//
|
||||||
let bottomUiEl = $state<HTMLElement | null>(null);
|
// 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
|
||||||
// Route-level visibility for the fixed bottom UI (the mini player itself also
|
// above it and cannot render behind it. There is nothing to measure or
|
||||||
// self-gates on playback state; when it renders nothing the in-flow slot
|
// reserve; the old ResizeObserver/`bottomUiHeight`/padding scheme (which
|
||||||
// collapses to 0 and the ResizeObserver shrinks the reserved padding).
|
// started at 0 and kept regressing into "last row hidden behind the nav") is
|
||||||
// All layout-shell visibility/reservation rules live in one pure, unit-tested
|
// gone. See BottomUi.svelte.
|
||||||
// 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.
|
|
||||||
const pathname = $derived($page.url.pathname);
|
const pathname = $derived($page.url.pathname);
|
||||||
const showBottomNav = $derived(
|
const showBottomNav = $derived(
|
||||||
computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated })
|
computeShowBottomNav({ pathname, isAuthenticated: $isAuthenticated })
|
||||||
);
|
);
|
||||||
const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname }));
|
const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname }));
|
||||||
|
|
||||||
// The library and settings routes own their own full-height layout (their own
|
// Library/settings/player/login own their own full-height flex column
|
||||||
// scroll container + bottom-space reservation), so the root must leave their
|
// (header + scroller + their own in-flow BottomUi), so the root just clips
|
||||||
// wrapper as a plain non-scrolling box. Every other top-level page (search,
|
// and lets them manage layout. Every other route renders into the root's
|
||||||
// downloads, sessions, home) renders straight into the root, so the root
|
// scroller, with the root's in-flow BottomUi as a flex sibling below it.
|
||||||
// wrapper has to scroll AND reserve the fixed bottom UI's height — otherwise
|
|
||||||
// the mini player / bottom nav overlay the last rows of content.
|
|
||||||
const routeOwnsLayout = $derived(computeRouteOwnsLayout({ pathname }));
|
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 () => {
|
onMount(async () => {
|
||||||
// Detect platform first (synchronously, before any await) so the global
|
// 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
|
this wrapper must scroll and reserve the fixed bottom UI's measured
|
||||||
height so the mini player / bottom nav never overlap the last rows. -->
|
height so the mini player / bottom nav never overlap the last rows. -->
|
||||||
{#if routeOwnsLayout}
|
{#if routeOwnsLayout}
|
||||||
|
<!-- These routes own their own full-height flex column (header + scroller
|
||||||
|
+ their own in-flow BottomUi), so the root just clips and steps back. -->
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Scroller is flex-1/min-h-0; the in-flow BottomUi below is a flex
|
||||||
|
sibling, so the list is physically bounded above it and can never
|
||||||
|
render behind it. No measurement, no reserved padding. -->
|
||||||
<div
|
<div
|
||||||
class="flex-1 overflow-y-auto min-h-0"
|
class="flex-1 overflow-y-auto min-h-0"
|
||||||
style="padding-bottom: {showBottomUi ? reservedBottomPadding($bottomUiHeight) : '0'}; overscroll-behavior: contain"
|
style="overscroll-behavior: contain"
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,44 +199,17 @@
|
|||||||
<!-- Toast notifications (global) -->
|
<!-- Toast notifications (global) -->
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<!-- Fixed bottom UI: mini player stacked over the bottom nav, both in normal
|
<!-- Bottom UI (mini player + nav), in normal flex flow below the scroller.
|
||||||
flow inside one measured wrapper. The wrapper's height is observed and
|
Owned routes render their own BottomUi inside their own column instead. -->
|
||||||
published to `bottomUiHeight` so pages reserve exactly this much space.
|
{#if !routeOwnsLayout && (showBottomNav || showGlobalMiniPlayer)}
|
||||||
Mini player is first (visually on top, above the nav). -->
|
<BottomUi showMiniPlayer={showGlobalMiniPlayer} showNav={showBottomNav} />
|
||||||
{#if showBottomNav || showGlobalMiniPlayer}
|
|
||||||
<div bind:this={bottomUiEl} class="fixed bottom-0 left-0 right-0 z-40 flex flex-col">
|
|
||||||
{#if showGlobalMiniPlayer}
|
|
||||||
<MiniPlayer
|
|
||||||
media={$currentMedia}
|
|
||||||
isPlaying={$isPlaying}
|
|
||||||
position={$playbackPosition}
|
|
||||||
duration={$playbackDuration}
|
|
||||||
shuffle={$shuffle}
|
|
||||||
repeat={$repeat}
|
|
||||||
hasNext={$hasNext}
|
|
||||||
hasPrevious={$hasPrevious}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
onExpand={() => {
|
|
||||||
// Navigate to player page when mini player is expanded
|
|
||||||
if ($currentMedia) {
|
|
||||||
goto(`/player/${$currentMedia.id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSleepTimerClick={() => showSleepTimerModal.set(true)}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showBottomNav}
|
<!-- Sleep Timer Modal (global) -->
|
||||||
<BottomNav className="flex-shrink-0" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sleep Timer Modal -->
|
|
||||||
<SleepTimerModal
|
<SleepTimerModal
|
||||||
isOpen={$showSleepTimerModal}
|
isOpen={$showSleepTimerModal}
|
||||||
onClose={() => showSleepTimerModal.set(false)}
|
onClose={() => showSleepTimerModal.set(false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-center h-screen">
|
<div class="flex items-center justify-center h-screen">
|
||||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||||
|
|||||||
@@ -5,10 +5,9 @@
|
|||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
|
||||||
import { library } from "$lib/stores/library";
|
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 { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||||
import Search from "$lib/components/Search.svelte";
|
import Search from "$lib/components/Search.svelte";
|
||||||
|
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||||
|
|
||||||
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
|
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
|
||||||
@@ -190,18 +189,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Main content. The fixed bottom UI (mini player + nav) is owned entirely
|
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
|
||||||
by the root layout on every platform, and its live-measured height is
|
scroller is physically bounded above it and its last row can never
|
||||||
published to `bottomUiHeight`. We reserve exactly that here (plus a
|
render behind the nav — no measurement, no reserved padding. -->
|
||||||
little breathing room) so the last row never hides behind the nav. -->
|
|
||||||
<main
|
<main
|
||||||
class="flex-1 overflow-y-auto p-4 min-h-0"
|
class="flex-1 overflow-y-auto p-4 min-h-0"
|
||||||
style="padding-bottom: {reservedBottomPadding($bottomUiHeight, 1)}; overscroll-behavior: contain"
|
style="overscroll-behavior: contain"
|
||||||
onscroll={scrollGuard.onScroll}
|
onscroll={scrollGuard.onScroll}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Bottom UI (mini player + nav), in-flow below the scroller. -->
|
||||||
|
<BottomUi />
|
||||||
|
|
||||||
<!-- Sleep Timer Modal -->
|
<!-- Sleep Timer Modal -->
|
||||||
<SleepTimerModal
|
<SleepTimerModal
|
||||||
isOpen={showSleepTimerModal}
|
isOpen={showSleepTimerModal}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto space-y-8 p-6 pb-24 h-full overflow-y-auto">
|
<div class="max-w-2xl mx-auto space-y-8 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
|
||||||
<p class="text-gray-400">Configure playback and audio processing</p>
|
<p class="text-gray-400">Configure playback and audio processing</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user