feat(chrome): shared account menu and global app header

Move account actions (Settings, Downloads, Display preferences, Sign
out) out of the library-only header into a shared AccountMenu anchored in
a global AppHeader, available on every authenticated non-immersive
screen. Add a layoutShell helper deciding where chrome shows, expose
serverName/serverUrl auth stores, and a display view-mode preference. The
settings page also gains the UR-053 WiFi-only toggle.

TRACES: UR-054 | DR-075, DR-076, DR-077
This commit is contained in:
2026-07-23 20:03:12 +02:00
parent f25deba824
commit cf9472f04f
9 changed files with 643 additions and 19 deletions
+77
View File
@@ -0,0 +1,77 @@
<!--
Shared application header. Lifted out of the library layout so the account
menu (and desktop nav) are available on every authenticated, non-immersive
screen, not only under /library. Routes that need in-header search (the
library layout) pass it in via the `search` snippet; other routes omit it.
TRACES: UR-054 | DR-076
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { page } from "$app/stores";
import AccountMenu from "$lib/components/account/AccountMenu.svelte";
let { search }: { search?: Snippet } = $props();
const pathname = $derived($page.url.pathname);
</script>
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
<div class="px-4 py-3 flex items-center gap-4">
<!-- Logo -->
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
JellyTau
</a>
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-1">
<a
href="/"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Home
</a>
<a
href="/library"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Library
</a>
<a
href="/downloads"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Downloads
</a>
<a
href="/settings"
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
>
Settings
</a>
</nav>
<!-- Optional in-header search (library layout supplies it). -->
{#if search}
<div class="flex-1 max-w-md hidden md:block space-y-2">
{@render search()}
</div>
{/if}
<!-- Account menu, anchored to the user's identity. -->
<div class="ml-auto flex items-center gap-3">
<!-- Desktop: Downloads quick icon (kept per UX spec §1.2). -->
<a
href="/downloads"
class="hidden md:block text-gray-400 hover:text-white transition-colors"
title="Downloads"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<AccountMenu />
</div>
</div>
</header>
@@ -0,0 +1,155 @@
<!--
Shared account menu — one component for both breakpoints. Anchored to the
user's name/avatar, it groups the account-level destinations (Downloads,
Settings, Display) and Sign out. Available on every authenticated,
non-immersive screen via the shared AppHeader.
TRACES: UR-054 | DR-075
-->
<script lang="ts">
import { tick } from "svelte";
import { goto } from "$app/navigation";
import { auth, currentUser, serverName, serverUrl } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
let open = $state(false);
let triggerEl = $state<HTMLButtonElement | null>(null);
// Prefer the human server name; fall back to the bare host of the URL so the
// identity block always shows *something* server-identifying.
const serverHost = $derived.by(() => {
if ($serverName) return $serverName;
if (!$serverUrl) return "";
try {
return new URL($serverUrl).host;
} catch {
return $serverUrl;
}
});
const displayName = $derived($currentUser?.name ?? "Account");
const initial = $derived((displayName[0] ?? "?").toUpperCase());
async function close(returnFocus = true) {
open = false;
if (returnFocus) {
await tick();
triggerEl?.focus();
}
}
function toggle() {
open = !open;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && open) {
e.stopPropagation();
close();
}
}
async function handleLogout() {
await close(false);
await auth.logout();
library.reset();
goto("/");
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="relative">
<button
bind:this={triggerEl}
onclick={toggle}
class="flex items-center gap-2 rounded-full py-1 pl-1 pr-1 md:pr-3 text-gray-300 hover:text-white hover:bg-[var(--color-surface)] transition-colors"
aria-haspopup="menu"
aria-expanded={open}
aria-label="Account menu"
>
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-jellyfin)] text-sm font-semibold text-white"
aria-hidden="true"
>
{initial}
</span>
<span class="hidden md:inline text-sm">{displayName}</span>
</button>
{#if open}
<!-- Backdrop closes the menu on any outside click. -->
<div
class="fixed inset-0 z-40"
onclick={() => close()}
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }}
role="button"
tabindex="-1"
aria-label="Close account menu"
></div>
<div
class="absolute right-0 top-full mt-2 w-56 bg-[var(--color-surface)] rounded-lg shadow-lg border border-gray-700 py-1 z-50"
role="menu"
>
<!-- Identity block — not interactive. -->
<div class="px-4 py-3">
<p class="text-xs text-gray-500">Signed in as</p>
<p class="text-sm font-semibold text-white truncate">{displayName}</p>
{#if serverHost}
<p class="text-xs text-gray-400 truncate">{serverHost}</p>
{/if}
</div>
<div class="border-t border-gray-700 my-1"></div>
<a
href="/downloads"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Downloads
</a>
<a
href="/settings"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
<a
href="/settings#display"
role="menuitem"
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
onclick={() => close(false)}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8" />
</svg>
Display
</a>
<div class="border-t border-gray-700 my-1"></div>
<button
onclick={handleLogout}
role="menuitem"
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
</button>
</div>
{/if}
</div>
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
// Controllable store shims + spies, declared via vi.hoisted so they exist when
// the hoisted vi.mock factories run. A tiny writable shim avoids importing
// svelte inside the hoisted block.
const h = vi.hoisted(() => {
function shim<T>(initial: T) {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
set(v: T) {
value = v;
subs.forEach((fn) => fn(value));
},
subscribe(fn: (v: T) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
};
}
return {
currentUserStore: shim<{ name: string } | null>({ name: "Ada" }),
serverNameStore: shim<string | null>("Home Server"),
serverUrlStore: shim<string | null>("https://media.example.com"),
logout: vi.fn(async () => {}),
reset: vi.fn(),
goto: vi.fn(),
};
});
vi.mock("$lib/stores/auth", () => ({
auth: { logout: h.logout },
currentUser: { subscribe: h.currentUserStore.subscribe },
serverName: { subscribe: h.serverNameStore.subscribe },
serverUrl: { subscribe: h.serverUrlStore.subscribe },
}));
vi.mock("$lib/stores/library", () => ({
library: { reset: h.reset },
}));
vi.mock("$app/navigation", () => ({ goto: h.goto }));
import AccountMenu from "./AccountMenu.svelte";
function openMenu() {
const trigger = screen.getByRole("button", { name: "Account menu" });
fireEvent.click(trigger);
return trigger;
}
describe("AccountMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
h.currentUserStore.set({ name: "Ada" });
h.serverNameStore.set("Home Server");
h.serverUrlStore.set("https://media.example.com");
});
it("trigger toggles aria-expanded", async () => {
render(AccountMenu);
const trigger = screen.getByRole("button", { name: "Account menu" });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
await fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
await fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
it("renders the documented items in order", async () => {
render(AccountMenu);
openMenu();
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
});
it("shows the identity block with name and server host", async () => {
render(AccountMenu);
openMenu();
expect(screen.getByText("Signed in as")).toBeTruthy();
// "Ada" appears in both the trigger label and the identity block.
expect(screen.getAllByText("Ada").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Home Server")).toBeTruthy();
});
it("falls back to the URL host when no server name is set", async () => {
h.serverNameStore.set(null);
render(AccountMenu);
openMenu();
expect(screen.getByText("media.example.com")).toBeTruthy();
});
it("Escape closes the menu", async () => {
render(AccountMenu);
const trigger = openMenu();
expect(trigger.getAttribute("aria-expanded")).toBe("true");
await fireEvent.keyDown(window, { key: "Escape" });
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
it("backdrop click closes the menu", async () => {
render(AccountMenu);
const trigger = openMenu();
const backdrop = screen.getByRole("button", { name: "Close account menu" });
await fireEvent.click(backdrop);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
it("Sign out logs out, resets library state, and redirects home", async () => {
render(AccountMenu);
openMenu();
const signOut = screen.getByRole("menuitem", { name: "Sign out" });
await fireEvent.click(signOut);
expect(h.logout).toHaveBeenCalledOnce();
expect(h.reset).toHaveBeenCalledOnce();
expect(h.goto).toHaveBeenCalledWith("/");
});
it("Sign out is the last item, after the routine navigation", async () => {
render(AccountMenu);
openMenu();
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
expect(items[items.length - 1]).toBe("Sign out");
});
});
+2
View File
@@ -569,3 +569,5 @@ export const securityWarning = derived(auth, ($auth) => $auth.securityWarning);
export const authError = derived(auth, ($auth) => $auth.error); export const authError = derived(auth, ($auth) => $auth.error);
export const isVerifying = derived(auth, ($auth) => $auth.isVerifying); export const isVerifying = derived(auth, ($auth) => $auth.isVerifying);
export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified); export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified);
export const serverName = derived(auth, ($auth) => $auth.serverName);
export const serverUrl = derived(auth, ($auth) => $auth.serverUrl);
+69
View File
@@ -0,0 +1,69 @@
/**
* Display preference (grid/list) is a second view onto the library `viewMode`
* store. The Settings Display section and the library page-header toggle both
* drive it via `library.setViewMode`, so verifying the store writes through and
* persists covers the shared data path for both controls.
*
* TRACES: UR-029 | DR-077 | UT-*
*/
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
import { get } from "svelte/store";
const STORAGE_KEY = "jellytau-view-mode";
// jsdom here doesn't expose localStorage; stand in a minimal implementation.
const backing = new Map<string, string>();
const localStorageShim = {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => void backing.set(key, value),
removeItem: (key: string) => void backing.delete(key),
clear: () => backing.clear(),
};
// The library store imports the auth store and tauri events at module load;
// neither is exercised by these tests.
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("./auth", () => ({ auth: { getUserId: vi.fn() } }));
beforeAll(() => {
vi.stubGlobal("localStorage", localStorageShim);
});
afterAll(() => {
vi.unstubAllGlobals();
});
describe("viewMode display preference", () => {
beforeEach(() => {
backing.clear();
vi.resetModules();
});
it("setViewMode writes through to the derived store", async () => {
const { library, viewMode } = await import("./library");
library.setViewMode("list");
expect(get(viewMode)).toBe("list");
library.setViewMode("grid");
expect(get(viewMode)).toBe("grid");
});
it("setViewMode persists the choice to localStorage", async () => {
const { library } = await import("./library");
library.setViewMode("list");
expect(backing.get(STORAGE_KEY)).toBe("list");
});
it("a persisted list preference is restored on load", async () => {
backing.set(STORAGE_KEY, "list");
const { viewMode } = await import("./library");
expect(get(viewMode)).toBe("list");
});
it("defaults to grid when nothing is persisted", async () => {
const { viewMode } = await import("./library");
expect(get(viewMode)).toBe("grid");
});
});
+28
View File
@@ -18,6 +18,7 @@ import { describe, it, expect } from "vitest";
import { import {
showBottomNav, showBottomNav,
showGlobalMiniPlayer, showGlobalMiniPlayer,
showGlobalHeader,
routeOwnsLayout, routeOwnsLayout,
showBottomUi, showBottomUi,
} from "./layoutShell"; } from "./layoutShell";
@@ -87,6 +88,33 @@ describe("routeOwnsLayout", () => {
}); });
}); });
describe("showGlobalHeader", () => {
it("shows the root-owned header (with account menu) on home, search, downloads", () => {
// The whole point of UR-054: account actions reachable without going to Library.
expect(showGlobalHeader(authed("/"))).toBe(true);
expect(showGlobalHeader(authed("/search"))).toBe(true);
expect(showGlobalHeader(authed("/downloads"))).toBe(true);
});
it("does NOT show on library (it renders its own AppHeader)", () => {
expect(showGlobalHeader(authed("/library"))).toBe(false);
expect(showGlobalHeader(authed("/library/abc"))).toBe(false);
});
it("does NOT show on settings (owns its content; no account menu needed there)", () => {
expect(showGlobalHeader(authed("/settings"))).toBe(false);
});
it("does NOT show on the immersive player or login", () => {
expect(showGlobalHeader(authed("/player/x"))).toBe(false);
expect(showGlobalHeader(authed("/login"))).toBe(false);
});
it("hides when unauthenticated", () => {
expect(showGlobalHeader({ pathname: "/", isAuthenticated: false })).toBe(false);
});
});
describe("structural invariant: every route that shows bottom UI has a scroller above it", () => { describe("structural invariant: every route that shows bottom UI has a scroller above it", () => {
// With the in-flow model, "the bottom UI is a flex sibling below a scroller" // With the in-flow model, "the bottom UI is a flex sibling below a scroller"
// must hold on every route where it shows. That scroller is provided by // must hold on every route where it shows. That scroller is provided by
+23
View File
@@ -67,6 +67,29 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
); );
} }
/**
* Whether the root layout should render the shared app header (logo, desktop
* nav, and the account menu) for this route.
*
* Routes that own their layout (library) render their own AppHeader, so the
* root must not double it up. `/settings` owns its content but deliberately has
* no account menu (the user is already there). `/player/*` and `/login` are
* immersive/chrome-free. Everything else authenticated (`/`, `/search`,
* `/downloads`) gets the header from the root — the whole point of UR-054.
*
* TRACES: UR-054 | DR-076
*/
export function showGlobalHeader({
pathname,
isAuthenticated,
}: BottomUiVisibilityInput): boolean {
return (
isAuthenticated &&
!routeOwnsLayout({ pathname }) &&
!pathname.startsWith("/settings")
);
}
/** /**
* Whether any bottom UI is showing for this route (mini player, nav, or both). * Whether any bottom UI is showing for this route (mini player, nav, or both).
* The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte), * The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte),
+24
View File
@@ -16,16 +16,22 @@
import Toast from "$lib/components/Toast.svelte"; import Toast from "$lib/components/Toast.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte"; import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomUi from "$lib/components/BottomUi.svelte"; import BottomUi from "$lib/components/BottomUi.svelte";
import AppHeader from "$lib/components/AppHeader.svelte";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState"; import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
import { import {
showBottomNav as computeShowBottomNav, showBottomNav as computeShowBottomNav,
showGlobalMiniPlayer as computeShowGlobalMiniPlayer, showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
showGlobalHeader as computeShowGlobalHeader,
routeOwnsLayout as computeRouteOwnsLayout, routeOwnsLayout as computeRouteOwnsLayout,
} from "$lib/utils/layoutShell"; } from "$lib/utils/layoutShell";
import { registerNavigationTracking } from "$lib/utils/navigation"; import { registerNavigationTracking } from "$lib/utils/navigation";
import { startNetworkReporting } from "$lib/services/networkType";
let { children } = $props(); let { children } = $props();
/** Teardown for the network-transport reporter (WiFi-only gate). */
let stopNetworkReporting: (() => void) | null = null;
// Track in-app navigation depth so the header "back" affordance knows when a // 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 / // real in-app Back exists (vs. a stale WebView stack after a background /
// restore). Must run during component init — afterNavigate needs a component // restore). Must run during component init — afterNavigate needs a component
@@ -47,6 +53,13 @@
); );
const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname })); 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 // Library/settings/player/login own their own full-height flex column
// (header + scroller + their own in-flow BottomUi), so the root just clips // (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 // and lets them manage layout. Every other route renders into the root's
@@ -76,6 +89,11 @@
// Initialize download event listener // Initialize download event listener
await initDownloadEvents(); await initDownloadEvents();
// 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, // Prime the downloads store from the DB. The store starts empty each launch,
// and download badges (e.g. AlbumDownloadButton) derive purely from it, so // and download badges (e.g. AlbumDownloadButton) derive purely from it, so
// without an initial refresh a previously-downloaded album shows as // without an initial refresh a previously-downloaded album shows as
@@ -102,6 +120,7 @@
}); });
onDestroy(() => { onDestroy(() => {
stopNetworkReporting?.();
cleanupPlayerEvents(); cleanupPlayerEvents();
cleanupDownloadEvents(); cleanupDownloadEvents();
connectivity.stopMonitoring(); connectivity.stopMonitoring();
@@ -189,6 +208,11 @@
{@render children()} {@render children()}
</div> </div>
{:else} {: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 <!-- 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 sibling, so the list is physically bounded above it and can never
render behind it. No measurement, no reserved padding. --> render behind it. No measurement, no reserved padding. -->
+138 -19
View File
@@ -1,8 +1,13 @@
<!-- TRACES: UR-023 | DR-048 --> <!-- TRACES: UR-023, UR-029 | DR-048, DR-077 -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import type { AudioSettings, VideoSettings, VolumeLevel } from "$lib/api/bindings"; import type {
AudioSettings,
CacheConfig,
VideoSettings,
VolumeLevel,
} from "$lib/api/bindings";
import { import {
getCacheStats, getCacheStats,
setCacheLimit, setCacheLimit,
@@ -12,6 +17,13 @@
bytesToGb, bytesToGb,
type ImageCacheStats, type ImageCacheStats,
} from "$lib/services/imageCache"; } from "$lib/services/imageCache";
import { getCacheConfig, updateCacheConfig } from "$lib/services/preload";
import SearchGroupOrderList from "$lib/components/settings/SearchGroupOrderList.svelte";
import { library, viewMode } from "$lib/stores/library";
import {
isNetworkDetectionSupported,
reportNetworkState,
} from "$lib/services/networkType";
const episodeLimitOptions = [ const episodeLimitOptions = [
{ value: 0, label: "Unlimited" }, { value: 0, label: "Unlimited" },
@@ -35,6 +47,21 @@
autoPlayMaxEpisodes: 0, autoPlayMaxEpisodes: 0,
}); });
// Download/caching behaviour, incl. the WiFi-only gate (UR-053).
let cacheConfig = $state<CacheConfig>({
queuePrecacheEnabled: true,
queuePrecacheCount: 3,
albumAffinityEnabled: true,
albumAffinityThreshold: 3,
storageLimit: 10 * 1024 * 1024 * 1024,
wifiOnly: false,
});
// Whether the platform can actually detect the network type. On desktop it
// can't, so the WiFi-only toggle would be inert — we disable and explain it
// rather than offering a switch that does nothing.
let networkDetectionSupported = $state(false);
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let saveMessage = $state(""); let saveMessage = $state("");
@@ -60,12 +87,15 @@
async function loadSettings() { async function loadSettings() {
try { try {
loading = true; loading = true;
const [audioResult, videoResult] = await Promise.all([ networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult] = await Promise.all([
commands.playerGetAudioSettings(), commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(), commands.playerGetVideoSettings(),
getCacheConfig(),
]); ]);
settings = audioResult; settings = audioResult;
videoSettings = videoResult; videoSettings = videoResult;
cacheConfig = cacheResult;
// Load cache stats in parallel but don't block on it // Load cache stats in parallel but don't block on it
loadCacheStats(); loadCacheStats();
} catch (e) { } catch (e) {
@@ -130,7 +160,12 @@
await Promise.all([ await Promise.all([
commands.playerSetAudioSettings(settings), commands.playerSetAudioSettings(settings),
commands.playerSetVideoSettings(videoSettings), commands.playerSetVideoSettings(videoSettings),
updateCacheConfig(cacheConfig),
]); ]);
// Re-report the network so the backend re-evaluates the gate against the
// just-saved wifi-only preference, releasing or holding the queue now
// rather than at the next network change.
await reportNetworkState();
saveMessage = "Settings saved successfully!"; saveMessage = "Settings saved successfully!";
setTimeout(() => { setTimeout(() => {
saveMessage = ""; saveMessage = "";
@@ -172,8 +207,8 @@
<div class="max-w-2xl mx-auto space-y-8 p-6"> <div class="max-w-2xl mx-auto space-y-8 p-6">
<div> <div>
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1> <h1 class="text-3xl font-bold text-white mb-2">Settings</h1>
<p class="text-gray-400">Configure playback and audio processing</p> <p class="text-gray-400">Configure display, playback, and downloads</p>
</div> </div>
{#if loading} {#if loading}
@@ -182,6 +217,47 @@
</div> </div>
{:else} {:else}
<div class="space-y-6"> <div class="space-y-6">
<!-- Display — grid/list preference, a second view onto the library
viewMode store (same source of truth as the library page-header
toggle, so the two stay in sync for free). -->
<div id="display" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white">Display</h2>
<p class="text-sm text-gray-400 mt-1">
How your library and collections are laid out
</p>
</div>
<p class="text-sm font-medium text-gray-300 mb-3">Layout</p>
<div class="grid grid-cols-2 gap-3">
<button
onclick={() => library.setViewMode("grid")}
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg transition-all {$viewMode ===
'grid'
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
aria-pressed={$viewMode === "grid"}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7z" />
</svg>
<span class="font-semibold">Grid</span>
</button>
<button
onclick={() => library.setViewMode("list")}
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg transition-all {$viewMode ===
'list'
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
aria-pressed={$viewMode === "list"}
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z" />
</svg>
<span class="font-semibold">List</span>
</button>
</div>
</div>
<!-- Crossfade --> <!-- Crossfade -->
<div class="bg-[var(--color-surface)] rounded-lg p-6"> <div class="bg-[var(--color-surface)] rounded-lg p-6">
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
@@ -384,6 +460,20 @@
</div> </div>
</div> </div>
<!-- Search Settings -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6">
<h3 class="text-lg font-semibold text-white mb-1">Result Group Order</h3>
<p class="text-sm text-gray-400 mb-4">
Drag or use the arrows to choose the order search result groups appear in.
Empty groups are hidden automatically.
</p>
<SearchGroupOrderList />
</div>
</div>
<!-- Image Cache Settings --> <!-- Image Cache Settings -->
<div class="border-t border-gray-700 pt-6"> <div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Image Cache</h2> <h2 class="text-2xl font-bold text-white mb-4">Image Cache</h2>
@@ -507,12 +597,19 @@
</p> </p>
</div> </div>
<button <button
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]" onclick={() =>
aria-label="Toggle album affinity (coming soon)" (cacheConfig.albumAffinityEnabled =
disabled !cacheConfig.albumAffinityEnabled)}
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.albumAffinityEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle smart caching"
aria-pressed={cacheConfig.albumAffinityEnabled}
> >
<span <span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform translate-x-7" class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.albumAffinityEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span> ></span>
</button> </button>
</div> </div>
@@ -524,16 +621,24 @@
<div> <div>
<h3 class="text-xl font-semibold text-white">Queue Pre-caching</h3> <h3 class="text-xl font-semibold text-white">Queue Pre-caching</h3>
<p class="text-sm text-gray-400 mt-1"> <p class="text-sm text-gray-400 mt-1">
Download next 5 tracks in queue automatically Download the next {cacheConfig.queuePrecacheCount} tracks in the queue
automatically
</p> </p>
</div> </div>
<button <button
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]" onclick={() =>
aria-label="Toggle queue pre-caching (coming soon)" (cacheConfig.queuePrecacheEnabled =
disabled !cacheConfig.queuePrecacheEnabled)}
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.queuePrecacheEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle queue pre-caching"
aria-pressed={cacheConfig.queuePrecacheEnabled}
> >
<span <span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform translate-x-7" class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.queuePrecacheEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span> ></span>
</button> </button>
</div> </div>
@@ -545,16 +650,30 @@
<div> <div>
<h3 class="text-xl font-semibold text-white">WiFi Only</h3> <h3 class="text-xl font-semibold text-white">WiFi Only</h3>
<p class="text-sm text-gray-400 mt-1"> <p class="text-sm text-gray-400 mt-1">
Only download when connected to WiFi {#if networkDetectionSupported}
Hold downloads unless on an unmetered network. Cellular and
metered hotspots are excluded; WiFi and Ethernet are allowed.
{:else}
Only available on Android — this device has no metered
connection to detect.
{/if}
</p> </p>
</div> </div>
<button <button
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]" onclick={() => (cacheConfig.wifiOnly = !cacheConfig.wifiOnly)}
aria-label="Toggle WiFi only downloads (coming soon)" class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
disabled ? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'} {networkDetectionSupported
? ''
: 'opacity-50 cursor-not-allowed'}"
aria-label="Toggle WiFi only downloads"
aria-pressed={cacheConfig.wifiOnly}
disabled={!networkDetectionSupported}
> >
<span <span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform translate-x-7" class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.wifiOnly
? 'translate-x-7'
: 'translate-x-1'}"
></span> ></span>
</button> </button>
</div> </div>