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
+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">
import { onMount } from "svelte";
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 {
getCacheStats,
setCacheLimit,
@@ -12,6 +17,13 @@
bytesToGb,
type ImageCacheStats,
} 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 = [
{ value: 0, label: "Unlimited" },
@@ -35,6 +47,21 @@
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 saving = $state(false);
let saveMessage = $state("");
@@ -60,12 +87,15 @@
async function loadSettings() {
try {
loading = true;
const [audioResult, videoResult] = await Promise.all([
networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult] = await Promise.all([
commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(),
getCacheConfig(),
]);
settings = audioResult;
videoSettings = videoResult;
cacheConfig = cacheResult;
// Load cache stats in parallel but don't block on it
loadCacheStats();
} catch (e) {
@@ -130,7 +160,12 @@
await Promise.all([
commands.playerSetAudioSettings(settings),
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!";
setTimeout(() => {
saveMessage = "";
@@ -172,8 +207,8 @@
<div class="max-w-2xl mx-auto space-y-8 p-6">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
<p class="text-gray-400">Configure playback and audio processing</p>
<h1 class="text-3xl font-bold text-white mb-2">Settings</h1>
<p class="text-gray-400">Configure display, playback, and downloads</p>
</div>
{#if loading}
@@ -182,6 +217,47 @@
</div>
{:else}
<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 -->
<div class="bg-[var(--color-surface)] rounded-lg p-6">
<div class="flex items-start justify-between mb-4">
@@ -384,6 +460,20 @@
</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 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Image Cache</h2>
@@ -507,12 +597,19 @@
</p>
</div>
<button
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]"
aria-label="Toggle album affinity (coming soon)"
disabled
onclick={() =>
(cacheConfig.albumAffinityEnabled =
!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
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>
</button>
</div>
@@ -524,16 +621,24 @@
<div>
<h3 class="text-xl font-semibold text-white">Queue Pre-caching</h3>
<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>
</div>
<button
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]"
aria-label="Toggle queue pre-caching (coming soon)"
disabled
onclick={() =>
(cacheConfig.queuePrecacheEnabled =
!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
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>
</button>
</div>
@@ -545,16 +650,30 @@
<div>
<h3 class="text-xl font-semibold text-white">WiFi Only</h3>
<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>
</div>
<button
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors bg-[var(--color-jellyfin)]"
aria-label="Toggle WiFi only downloads (coming soon)"
disabled
onclick={() => (cacheConfig.wifiOnly = !cacheConfig.wifiOnly)}
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
? '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
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>
</button>
</div>