🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.
Offline video playback — four separate defects, each of which alone stopped it:
DR-133 A completed download's file_path is already absolute (the worker
rewrites it on completion), but the player rooted it a second time and
handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
DR-134 The asset protocol was never enabled: no protocol-asset feature and no
assetProtocol config, so convertFileSrc produced URLs nothing answered.
Also silently defeated the cached-thumbnail path, which fails soft to
the server copy and hid it whenever the server was reachable.
DR-137 Tauri's asset protocol answers a range-less request by reading the
whole file into memory, and only advertises Accept-Ranges from inside
its range branch, so the first request never learns ranges exist.
Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
now served by a loopback HTTP server: bounded 4 MiB chunks streamed
from the file handle, every response length-delimited, and a range-less
request answered with one chunk rather than the file. Confined by a
per-session token and to the app data directory, because loopback is
shared between apps on Android.
DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the
request to that server before any I/O. A network-security-config
exempts 127.0.0.1 only; a remote server must still be HTTPS.
Downloads:
DR-135 download_item never records media_type and the reconnect resolver read
that NULL as 'audio', so a movie queued from a media card had its URL
resolved by get_audio_stream_url and completed as an audio-only
transcode. The item's own type now decides.
DR-136 Rows already downloaded that way are requeued on reconnect, since
prevention alone leaves them reading "downloaded" and still unplayable.
Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.
Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
352 lines
16 KiB
Svelte
352 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 { 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();
|
|
|
|
// 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
|
|
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>
|