layout and remote fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m29s
Build & Release / Build Linux (push) Successful in 17m27s
Build & Release / Build Android (push) Successful in 22m14s
Build & Release / Create Release (push) Successful in 12s

This commit is contained in:
2026-07-16 22:53:03 +02:00
parent 532ffa661a
commit 1992a8187d
10 changed files with 272 additions and 192 deletions
+31 -87
View File
@@ -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<HTMLElement | null>(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}
<!-- 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">
{@render children()}
</div>
{: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
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()}
</div>
@@ -228,44 +199,17 @@
<!-- Toast notifications (global) -->
<Toast />
<!-- Fixed bottom UI: mini player stacked over the bottom nav, both in normal
flow inside one measured wrapper. The wrapper's height is observed and
published to `bottomUiHeight` so pages reserve exactly this much space.
Mini player is first (visually on top, above the nav). -->
{#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 showBottomNav}
<BottomNav className="flex-shrink-0" />
{/if}
</div>
<!-- Sleep Timer Modal -->
<SleepTimerModal
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
<!-- Bottom UI (mini player + nav), in normal flex flow below the scroller.
Owned routes render their own BottomUi inside their own column instead. -->
{#if !routeOwnsLayout && (showBottomNav || showGlobalMiniPlayer)}
<BottomUi showMiniPlayer={showGlobalMiniPlayer} showNav={showBottomNav} />
{/if}
<!-- Sleep Timer Modal (global) -->
<SleepTimerModal
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
{:else}
<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>