DR-172 reverted native video to opt-in after it shipped as audio with no picture, naming the compositing as the suspect. The compositing was fine. Five separate defects sat between ExoPlayer and the screen, each able to produce that exact symptom on its own, and each invisible to the others. DR-185 — the app shell painted over the surface. app.css clears the page's opaque layers through three selectors, one of which targets `[data-app-shell]`, an attribute NO component has ever set, in any commit. The shell paints --color-background across the whole viewport and VideoPlayer stacks above it, so the WebView composited opaque no matter what else was cleared. Invisible three ways over: the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent. DR-182 — nothing could lift the poster card. Every markMediaReady() call site is an HTML5 <video> event, and the native branch renders no element, so the black title card covered the surface for the entire session. The first fix hooked `player://position-update` / `player://state-changed`; those channels are never emitted by the backend, so it passed a test that fired them by hand and did nothing on a device. Driven from the player store now, as the seek bar already was. DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by walking the view tree, while WebView binds injected objects at page-load time, and the identity guard then declined to re-inject forever. setTransparent(true) could never arrive. Installed from WryActivity.onWebViewCreate instead, which wry calls immediately before the first loadUrl. DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers anywhere, mirroring the DR-151 defect: every native video left its surface parented to the content view and the next one stacked another beneath it. DR-191 — the overlay stopped repainting. Incremental damage (the clock's text, the control bar's opacity) never reached the screen while structural changes did, so the progress bar froze, the controls would not fade, and the play overlay appeared to work because it is added and removed from the DOM. Driven from the Activity via postInvalidateOnAnimation while compositing is on. Two UI defects only this path could reveal came with them: isPlaying froze at its initial value, leaving the play overlay dimming and covering the video (DR-186), and the control bar's auto-hide was armed solely by mousemove, which a touchscreen never fires (DR-189). Immersive mode now applies on entering the player rather than only via the fullscreen button (DR-187). Verified on a device (Honor ROD2-W09, Android 16): logcat carries `WebView transparent = true` and `Marking media ready` with video on screen — the pair DR-172 went looking for and could not find — and skip, seek, rotation and subtitle rendering were exercised by hand. The default stays OFF (DR-188). Turning it on surfaced a further unverified sub-path: returning from background audio is HTML5-only, so playback stays dead (DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified sub-path made default over an unverified one.
369 lines
16 KiB
Svelte
369 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
|
|
-->
|
|
<!--
|
|
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>
|