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:
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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. -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user