diff --git a/src/lib/components/AppHeader.svelte b/src/lib/components/AppHeader.svelte new file mode 100644 index 00000000..3e907fb3 --- /dev/null +++ b/src/lib/components/AppHeader.svelte @@ -0,0 +1,77 @@ + + + +
+
+ + + JellyTau + + + + + + + {#if search} + + {/if} + + +
+ + + + +
+
+
diff --git a/src/lib/components/account/AccountMenu.svelte b/src/lib/components/account/AccountMenu.svelte new file mode 100644 index 00000000..c1fa616f --- /dev/null +++ b/src/lib/components/account/AccountMenu.svelte @@ -0,0 +1,155 @@ + + + + + +
+ + + {#if open} + +
close()} + onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }} + role="button" + tabindex="-1" + aria-label="Close account menu" + >
+ + + {/if} +
diff --git a/src/lib/components/account/AccountMenu.test.ts b/src/lib/components/account/AccountMenu.test.ts new file mode 100644 index 00000000..9c403d7f --- /dev/null +++ b/src/lib/components/account/AccountMenu.test.ts @@ -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(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("Home Server"), + serverUrlStore: shim("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"); + }); +}); diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index fed840b8..77813262 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -569,3 +569,5 @@ export const securityWarning = derived(auth, ($auth) => $auth.securityWarning); export const authError = derived(auth, ($auth) => $auth.error); export const isVerifying = derived(auth, ($auth) => $auth.isVerifying); export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified); +export const serverName = derived(auth, ($auth) => $auth.serverName); +export const serverUrl = derived(auth, ($auth) => $auth.serverUrl); diff --git a/src/lib/stores/viewMode.test.ts b/src/lib/stores/viewMode.test.ts new file mode 100644 index 00000000..cf3b4f5e --- /dev/null +++ b/src/lib/stores/viewMode.test.ts @@ -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(); +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"); + }); +}); diff --git a/src/lib/utils/layoutShell.test.ts b/src/lib/utils/layoutShell.test.ts index 89453ea1..c17a118d 100644 --- a/src/lib/utils/layoutShell.test.ts +++ b/src/lib/utils/layoutShell.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect } from "vitest"; import { showBottomNav, showGlobalMiniPlayer, + showGlobalHeader, routeOwnsLayout, showBottomUi, } 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", () => { // 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 diff --git a/src/lib/utils/layoutShell.ts b/src/lib/utils/layoutShell.ts index e98aa00e..f10c7650 100644 --- a/src/lib/utils/layoutShell.ts +++ b/src/lib/utils/layoutShell.ts @@ -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). * The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte), diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 80fd5210..091b69f3 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -16,16 +16,22 @@ 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 { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState"; import { showBottomNav as computeShowBottomNav, showGlobalMiniPlayer as computeShowGlobalMiniPlayer, + showGlobalHeader as computeShowGlobalHeader, routeOwnsLayout as computeRouteOwnsLayout, } from "$lib/utils/layoutShell"; import { registerNavigationTracking } from "$lib/utils/navigation"; + import { startNetworkReporting } from "$lib/services/networkType"; 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 // real in-app Back exists (vs. a stale WebView stack after a background / // restore). Must run during component init — afterNavigate needs a component @@ -47,6 +53,13 @@ ); 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 @@ -76,6 +89,11 @@ // Initialize download event listener 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, // and download badges (e.g. AlbumDownloadButton) derive purely from it, so // without an initial refresh a previously-downloaded album shows as @@ -102,6 +120,7 @@ }); onDestroy(() => { + stopNetworkReporting?.(); cleanupPlayerEvents(); cleanupDownloadEvents(); connectivity.stopMonitoring(); @@ -189,6 +208,11 @@ {@render children()} {:else} + + {#if showGlobalHeader} + + {/if} diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 21af03ed..ab51046d 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,8 +1,13 @@ - +