Files
jellytau/src/routes/+layout.svelte
T
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00

360 lines
16 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { get } from "svelte/store";
import { page } from "$app/stores";
import { platform } from "@tauri-apps/plugin-os";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import "../app.css";
import { commands } from "$lib/api/bindings";
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
import { connectivity, isConnected } from "$lib/stores/connectivity";
import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents";
import { initWebviewAudio, cleanupWebviewAudio } from "$lib/services/webviewAudio";
import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
import { syncService } from "$lib/services/syncService";
import { clearFavorite } from "$lib/stores/favorites";
import { onReconnected as onCatalogReconnected, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
import { playbackMode } from "$lib/stores/playbackMode";
import { sessions } from "$lib/stores/sessions";
import ReauthModal from "$lib/components/auth/ReauthModal.svelte";
import Toast from "$lib/components/Toast.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import AppHeader from "$lib/components/AppHeader.svelte";
import PendingSyncModal from "$lib/components/sync/PendingSyncModal.svelte";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
import {
showBottomNav as computeShowBottomNav,
showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
showGlobalHeader as computeShowGlobalHeader,
routeOwnsLayout as computeRouteOwnsLayout,
shellReservesBottomInset,
} from "$lib/utils/layoutShell";
import { registerNavigationTracking } from "$lib/utils/navigation";
import { useScrollRestore } from "$lib/utils/scrollContainer";
import { startNetworkReporting } from "$lib/services/networkType";
import { initSafeArea } from "$lib/utils/safeArea";
let { children } = $props();
/** Offline banner badge → the list of what is actually queued (DR-132). */
let showPendingSync = $state(false);
/** Teardown for the network-transport reporter (WiFi-only gate). */
let stopNetworkReporting: (() => void) | null = null;
let stopFavoritesListener: UnlistenFn | null = null;
/** Teardown for the native window-inset subscription (safe areas). */
let stopSafeArea: (() => void) | null = null;
// Track in-app navigation depth so the header "back" affordance knows when a
// real in-app Back exists (vs. a stale WebView stack after a background /
// restore). Must run during component init — afterNavigate needs a component
// context, not the async onMount callback below.
registerNavigationTracking();
// The shell's scroller outlives every route rendered into it, so without this
// a new page inherits the previous page's offset. Must be registered here at
// init, alongside the tracker above, for the same reason. (DR-156)
let shellScroller = $state<HTMLElement>();
useScrollRestore(() => shellScroller, "shell");
// 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 shared header (with the account menu) is rendered by the root on every
// authenticated non-immersive route that doesn't own its own layout. Library
// renders its own AppHeader; settings/player/login get none. (UR-054)
const showGlobalHeader = $derived(
computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated })
);
// 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 }));
// Bottom safe-area inset (Android navigation/gesture bar): BottomUi reserves
// it wherever one renders, so the shell only takes it on routes that have no
// bottom UI at all (login, the full-screen player). Exactly one owner, or the
// bar is either ignored or double-padded. (UR-066)
const shellPadsBottom = $derived(
shellReservesBottomInset({ pathname, isAuthenticated: $isAuthenticated })
);
onMount(async () => {
// Detect platform first (synchronously, before any await) so the global
// mini player's Android visibility gate is correct from the first render.
// Android needs the global mini player because the library layout hides its
// own; if this ran after `await auth.initialize()` the store stayed false
// long enough that the mini player never appeared on library routes.
try {
const platformName = platform();
isAndroid.set(platformName === "android");
} catch (err) {
console.error("Platform detection failed:", err);
}
// Prime the safe-area custom properties from the native WindowInsets bridge
// BEFORE the first await, so the very first paint already clears the status
// bar and the navigation/gesture bar. Native also pushes updates directly,
// but a page load wipes the inline style it set, so this pull is required.
// No-op without the Android bridge. (UR-066)
stopSafeArea = initSafeArea();
// Initialize auth state (restore session from secure storage)
await auth.initialize();
isInitialized.set(true);
// Initialize player event listener for push-based updates
await initPlayerEvents();
// Initialize the webview audio controller (plays audio-only media in an
// <audio> element on platforms with no native audio backend, e.g. Windows;
// self-gates and is a no-op on Linux/Android).
await initWebviewAudio();
// Initialize download event listener
await initDownloadEvents();
// Favourite state can change behind the UI: another Jellyfin client marks
// something, or the Rust drain pushes toggles queued while offline. Drop
// the session overrides for those ids so the next render reads the freshly
// cached server value rather than a stale local guess.
// TRACES: UR-069 | DR-120
stopFavoritesListener = await listen<{ itemIds: string[] }>(
"favorites-changed",
(event) => {
for (const id of event.payload?.itemIds ?? []) clearFavorite(id);
}
);
// Report the network transport to the backend and keep it current, so the
// WiFi-only download gate has real data to act on (UR-053). No-op on
// desktop, where the backend defaults to unmetered.
stopNetworkReporting = startNetworkReporting();
// Prime the downloads store from the DB. The store starts empty each launch,
// and download badges (e.g. AlbumDownloadButton) derive purely from it, so
// without an initial refresh a previously-downloaded album shows as
// not-downloaded until the user opens the Downloads page.
const userId = get(auth).user?.id;
if (userId) {
downloads.refresh(userId).catch((err) =>
console.error("Initial downloads refresh failed:", err)
);
}
// Start sync service for offline mutation queue
syncService.start();
// Kick the queue once at startup. The Rust drain otherwise only runs on an
// offline→online transition, so a queue built up in a previous session sits
// untouched for a whole run of the app if the server was reachable the
// whole time. Safe when it isn't: an unreachable server leaves rows queued
// without spending their retry budget (DR-131).
if (get(auth).user?.id) {
commands.syncProcessPending().catch((err) =>
console.debug("[Layout] Startup sync drain skipped:", err)
);
}
// Load the last-sync hint for the offline banner. The catalog *index* is no
// longer kicked off from here: the Rust background indexer (DR-109) owns
// when to re-index, so a long session no longer searches a stale catalog and
// a restart no longer forces a full crawl regardless of freshness.
void refreshSyncStatus();
// Initialize playback mode and session monitoring
playbackMode.initializeSessionMonitoring();
await playbackMode.refresh();
});
onDestroy(() => {
stopNetworkReporting?.();
stopFavoritesListener?.();
stopFavoritesListener = null;
stopSafeArea?.();
cleanupPlayerEvents();
cleanupWebviewAudio();
cleanupDownloadEvents();
connectivity.stopMonitoring();
syncService.stop();
auth.cleanupEventListeners();
});
// Connectivity monitoring is now started early in auth.initialize()
// This effect is kept only for when the user logs in during the session
$effect(() => {
if ($isAuthenticated) {
// Check if monitoring is already running by attempting to get status
// If not running, start it (handles login during current session)
auth.getCurrentSession().then((session) => {
if (session?.serverUrl) {
connectivity.forceCheck().catch((error) => {
// If check fails, monitoring might not be started yet, so start it
console.debug("[Layout] Queue status check failed, starting monitoring:", error);
connectivity.startMonitoring(session.serverUrl, {
onServerReconnected: () => {
// Retry session verification when server becomes reachable
auth.retryVerification();
// Resume offline-queued downloads and refresh the catalog.
void onCatalogReconnected();
},
}).catch((monitorError) => {
console.error("[Layout] Failed to start connectivity monitoring:", monitorError);
});
});
}
});
}
});
// Update pending sync count periodically, and immediately whenever the Rust
// drain (DR-131) reports it pushed or gave up on rows — otherwise the badge
// lags a reconnect by up to 10s and reads as if nothing happened.
$effect(() => {
if ($isAuthenticated) {
const updateCount = async () => {
const count = await syncService.getPendingCount();
pendingSyncCount.set(count);
};
updateCount();
// Update every 10 seconds
const interval = setInterval(updateCount, 10000);
let unlistenDrain: UnlistenFn | null = null;
listen("sync-queue-changed", () => {
void updateCount();
})
.then((unlisten) => {
unlistenDrain = unlisten;
})
.catch((err) => console.debug("[Layout] sync-queue-changed listen failed:", err));
return () => {
clearInterval(interval);
unlistenDrain?.();
};
}
});
</script>
<!--
The app shell reserves the top/side safe-area insets (status bar, display
cutout) so no route has to. The bottom inset belongs to BottomUi wherever one
renders — see shellReservesBottomInset. `h-screen` is border-box, so the
padding is taken out of the 100vh rather than added to it.
TRACES: UR-066 | DR-112
-->
<div
class="h-screen bg-[var(--color-background)] overflow-hidden flex flex-col
pt-[var(--safe-top)] pl-[var(--safe-left)] pr-[var(--safe-right)]"
style:padding-bottom={shellPadsBottom ? "var(--safe-bottom)" : undefined}
>
{#if isInitialized}
<!-- Offline indicator banner -->
{#if $isAuthenticated && !$isConnected}
<div class="bg-amber-600/90 text-white px-4 py-2 text-sm flex items-center justify-center gap-2 shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
</svg>
<span>You're offline. Some features may be limited.</span>
<!-- The badge is answerable: it opens the queue it counts. Read as
"pending transfers" it used to send people to the Downloads page,
which lists a different table entirely and can never show these.
TRACES: UR-025 | DR-132 -->
{#if $pendingSyncCount > 0}
<button
type="button"
onclick={() => (showPendingSync = true)}
class="bg-white/20 hover:bg-white/30 px-2 py-0.5 rounded-full text-xs transition-colors"
title="Changes waiting to reach the server"
>
{$pendingSyncCount} waiting to sync
</button>
{/if}
<button
type="button"
onclick={() => showServerCatalog.update((v) => !v)}
class="ml-1 bg-white/20 hover:bg-white/30 px-2 py-0.5 rounded-full text-xs transition-colors"
aria-pressed={$showServerCatalog}
title={$lastCatalogSync ? `Catalog last synced ${new Date($lastCatalogSync).toLocaleString()}` : 'Catalog not yet synced'}
>
{$showServerCatalog ? 'Hide server media' : 'Show all server media'}
</button>
</div>
{/if}
<!-- Main content. Routes that own their full-height layout (library,
settings, player, login) get a plain clipped box and manage their own
scrolling internally. All other top-level pages render directly here, so
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}
<!-- Shared header (account menu, desktop nav) as a flex-shrink-0 sibling
above the scroller, so it never eats into the scroller's bounds. -->
{#if showGlobalHeader}
<AppHeader />
{/if}
<!-- 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
bind:this={shellScroller}
class="flex-1 overflow-y-auto min-h-0"
style="overscroll-behavior: contain"
>
{@render children()}
</div>
{/if}
<!-- Re-authentication modal -->
<ReauthModal isOpen={$needsReauth} />
<!-- Toast notifications (global) -->
<Toast />
<!-- 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)}
/>
<!-- What the offline banner's badge counts (DR-132) -->
<PendingSyncModal
isOpen={showPendingSync}
onClose={() => (showPendingSync = false)}
/>
{:else}
<div class="flex items-center justify-center h-full">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{/if}
</div>