Files
jellytau/src/routes/+layout.svelte
T
dtourolle d54d8cc7c4 refactor(logging): route frontend console calls through the logger
TRACES: | DR-204

484 ungated `console.*` calls across 63 non-test frontend files shipped to
end users with no way to turn them off. Mechanical substitution, no control
flow, error handling or message semantics changed:

  console.log / console.debug -> log.debug
  console.info                -> log.info
  console.warn                -> log.warn
  console.error               -> log.error

Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope
now carries them; scope names that already existed are preserved verbatim
(`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename
where a file had none. `src/routes/player/[id]/+page.svelte` keeps its
`NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than
flattening them into the page scope.

`grep -rn 'console\.' src/` now matches nothing outside the tests and the
facade itself.
2026-08-20 19:29:59 +02:00

372 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";
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>