Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped

This commit is contained in:
2026-07-11 19:55:55 +02:00
parent a2cd9978f0
commit 2a1f1689b4
20 changed files with 991 additions and 995 deletions
+18 -14
View File
@@ -20,6 +20,13 @@
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 {
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";
@@ -34,16 +41,16 @@
// 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.
const pathname = $derived($page.url.pathname);
const showBottomNav = $derived(
$isAuthenticated && !pathname.startsWith('/player/') && !pathname.startsWith('/login')
);
const showGlobalMiniPlayer = $derived(
!pathname.startsWith('/player/') &&
!pathname.startsWith('/login') &&
!pathname.startsWith('/settings') &&
($isAndroid || !pathname.startsWith('/library'))
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
@@ -51,13 +58,10 @@
// 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.
const routeOwnsLayout = $derived(
pathname.startsWith('/library') ||
pathname.startsWith('/settings') ||
pathname.startsWith('/player/') ||
pathname.startsWith('/login')
const routeOwnsLayout = $derived(computeRouteOwnsLayout({ pathname }));
const showBottomUi = $derived(
computeShowBottomUi({ pathname, isAuthenticated: $isAuthenticated })
);
const showBottomUi = $derived(showBottomNav || showGlobalMiniPlayer);
$effect(() => {
const el = bottomUiEl;
@@ -212,7 +216,7 @@
{:else}
<div
class="flex-1 overflow-y-auto min-h-0"
style="padding-bottom: {showBottomUi ? `${$bottomUiHeight}px` : '0'}; overscroll-behavior: contain"
style="padding-bottom: {showBottomUi ? reservedBottomPadding($bottomUiHeight) : '0'}; overscroll-behavior: contain"
>
{@render children()}
</div>
+7 -60
View File
@@ -5,13 +5,10 @@
import { commands } from "$lib/api/bindings";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
import { isShuffle, repeatMode, hasNext as hasNextStore, hasPrevious as hasPreviousStore } from "$lib/stores/queue";
import { isAndroid, bottomUiHeight } from "$lib/stores/appState";
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 MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import AudioPlayer from "$lib/components/player/AudioPlayer.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
// Scroll guard prevents accidental taps on library cards during/after scrolling (Android)
@@ -21,19 +18,8 @@
let { children } = $props();
let searchQuery = $state("");
let showFullPlayer = $state(false);
let showOverflowMenu = $state(false);
let showSleepTimerModal = $state(false);
// Queue status (shuffle, repeat, hasNext, hasPrevious) is event-driven via the
// queue store, which listens for queue_changed events from the backend. This
// updates instantly when toggling shuffle/repeat (no polling lag).
const shuffle = $derived($isShuffle);
const repeat = $derived($repeatMode);
const hasNext = $derived($hasNextStore);
const hasPrevious = $derived($hasPreviousStore);
// Platform comes from the shared appState store (set once in the root layout),
// so this layout and the root layout never disagree about Android — otherwise
// both can suppress their mini players and none appears. See $lib/stores/appState.
onMount(() => {
return () => {
@@ -204,57 +190,18 @@
</div>
</header>
<!-- Main content. The mini player is an in-flow flex sibling below this
scroller (not a fixed overlay), so the list can never render behind it.
Android keeps the global fixed bottom UI (nav + mini player), so there we
reserve its real measured height (`bottomUiHeight`, observed live in the
root layout) plus a little breathing room. Non-Android reserves none —
the in-flow bar below owns that space. -->
<!-- Main content. The fixed bottom UI (mini player + nav) is owned entirely
by the root layout on every platform, and its live-measured height is
published to `bottomUiHeight`. We reserve exactly that here (plus a
little breathing room) so the last row never hides behind the nav. -->
<main
class="flex-1 overflow-y-auto p-4 min-h-0"
style="padding-bottom: {$isAndroid ? `calc(${$bottomUiHeight}px + 1rem)` : '1rem'}; overscroll-behavior: contain"
style="padding-bottom: {reservedBottomPadding($bottomUiHeight, 1)}; overscroll-behavior: contain"
onscroll={scrollGuard.onScroll}
>
{@render children()}
</main>
<!-- Mini Player (only show on non-Android platforms - Android uses global mini player) -->
<!-- Hide on player page since full player is already there. Rendered in
normal flex flow so it sits above the list rather than overlapping it. -->
{#if !$isAndroid && !$page.url.pathname.startsWith('/player/')}
<MiniPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
{shuffle}
{repeat}
{hasNext}
{hasPrevious}
className="flex-shrink-0"
onExpand={() => showFullPlayer = true}
onSleepTimerClick={() => showSleepTimerModal = true}
/>
{/if}
<!-- Full Audio Player -->
{#if showFullPlayer}
<AudioPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
{shuffle}
{repeat}
{hasNext}
{hasPrevious}
onClose={() => {
showFullPlayer = false;
window.history.back();
}}
/>
{/if}
<!-- Sleep Timer Modal -->
<SleepTimerModal
isOpen={showSleepTimerModal}