Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
382 lines
17 KiB
Svelte
382 lines
17 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";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("Layout");
|
|
|
|
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) {
|
|
log.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) => log.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) => log.debug("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
|
|
log.debug("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) => {
|
|
log.error("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) => log.debug("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
|
|
-->
|
|
<!--
|
|
data-app-shell marks the layer app.css clears for native video. This div
|
|
paints --color-background across the entire viewport, *under* a VideoPlayer
|
|
that makes itself transparent on the native path — so while it stays painted,
|
|
the ExoPlayer SurfaceView behind the WebView cannot be seen no matter what
|
|
else is cleared. The rule in app.css was written for this attribute; the
|
|
attribute was never added. (DR-185)
|
|
-->
|
|
<div
|
|
data-app-shell
|
|
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>
|