From 48aa1a25f539fac35ce938d70a4755d441f45775 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 03:07:25 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(prefs):=20usePref=20=E2=80=94=20one?= =?UTF-8?q?=20per-account+server=20preference=20hook=20(E4=20S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the hand-rolled server-pref plumbing (savePrefs + local mirror + me-cache merge + adopt-on-login) into a single usePref(serverKey, default, opts) hook, plus useSavePref for dynamic multi-key patches. Migrated (6 savePrefs call sites + 2 duplicated adopt effects -> the hook): - FeedViewProvider: view - App: filterCollapsed, playlistsCollapsed (+ their adopt block removed) - PlayerModal: playerAutoAdvance, playerLoop - plexDetailUi: plexInfoArtBg/Cast/Related (savePref via useSavePref) Left as-is by design: panel layouts (a dynamically-indexed Record, not one key), language (i18n store, no React state), PrefsProvider (debounced multi-field auto-save + baseline echo-suppression - a different model). Behaviour-equivalent (net -84 LOC at the call sites). usePref.dom.test covers the three seed sources, persist path, debounce, adopt, parse, and useSavePref. Frontend gate green: typecheck, knip, eslint (0 err), prettier, 238 vitest. --- frontend/src/App.tsx | 46 ++--- frontend/src/components/FeedViewProvider.tsx | 52 ++---- frontend/src/components/PlayerModal.tsx | 35 +--- frontend/src/lib/plexDetailUi.tsx | 47 ++--- frontend/src/lib/usePref.dom.test.ts | 174 +++++++++++++++++++ frontend/src/lib/usePref.ts | 132 ++++++++++++++ 6 files changed, 354 insertions(+), 132 deletions(-) create mode 100644 frontend/src/lib/usePref.dom.test.ts create mode 100644 frontend/src/lib/usePref.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c55b675c..13a21521 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,7 +15,8 @@ import { type PanelLayout, } from "./lib/panelLayout"; import { notify, removeByMetaKind } from "./lib/notifications"; -import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage"; +import { LS, setAccountRaw } from "./lib/storage"; +import { usePref } from "./lib/usePref"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; import { useChannelLayout } from "./components/ChannelsProvider"; @@ -90,26 +91,18 @@ export default function App() { } // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the - // user's preferences so it follows the account across devices; a localStorage cache seeds the - // initial render synchronously so nothing flashes open before the server prefs arrive. + // user's preferences (via usePref) so it follows the account across devices; the localStorage mirror + // seeds the initial render synchronously so nothing flashes open before the server prefs arrive. // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice. - const [filterCollapsed, setFilterCollapsedState] = useState(() => - Boolean(readAccount(LS.filterCollapsed, true)), - ); + const [filterCollapsed, setFilterCollapsed] = usePref("filterCollapsed", true, { + localKey: LS.filterCollapsed, + parse: Boolean, + }); // The Playlists rail has its own collapse flag (it's a list/navigator, not filters). - const [playlistsCollapsed, setPlaylistsCollapsedState] = useState(() => - Boolean(readAccount(LS.playlistsCollapsed, true)), - ); - function setFilterCollapsed(next: boolean) { - setFilterCollapsedState(next); - writeAccount(LS.filterCollapsed, next); - api.savePrefs({ filterCollapsed: next }).catch(() => {}); - } - function setPlaylistsCollapsed(next: boolean) { - setPlaylistsCollapsedState(next); - writeAccount(LS.playlistsCollapsed, next); - api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); - } + const [playlistsCollapsed, setPlaylistsCollapsed] = usePref("playlistsCollapsed", true, { + localKey: LS.playlistsCollapsed, + parse: Boolean, + }); // Filters live in localStorage, not the address bar. If we arrived via a "Share view" // link, its params have already hydrated the initial state — persist them and strip the // query so the URL stays clean from here on. @@ -213,9 +206,10 @@ export default function App() { stripUrlParams(); }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps - // On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags, - // language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider - // and the feed view by FeedViewProvider — each via its own passive qk.me() observer. + // On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse + // flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the + // Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by + // FeedViewProvider — each via its own passive qk.me() observer. useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; @@ -227,14 +221,6 @@ export default function App() { saveLayoutLocal(p, l); } }); - if (typeof prefs.filterCollapsed === "boolean") { - setFilterCollapsedState(prefs.filterCollapsed); - writeAccount(LS.filterCollapsed, prefs.filterCollapsed); - } - if (typeof prefs.playlistsCollapsed === "boolean") { - setPlaylistsCollapsedState(prefs.playlistsCollapsed); - writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed); - } const lang = prefs.language; if (typeof lang === "string" && isSupported(lang)) setLanguage(lang); // (Per-account filters — incl. the demo account's whole-library default — are loaded by the diff --git a/frontend/src/components/FeedViewProvider.tsx b/frontend/src/components/FeedViewProvider.tsx index ba515118..61ce301d 100644 --- a/frontend/src/components/FeedViewProvider.tsx +++ b/frontend/src/components/FeedViewProvider.tsx @@ -1,26 +1,15 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from "react"; -import { qk } from "../lib/queryKeys"; -import { useQuery } from "@tanstack/react-query"; -import { api } from "../lib/api"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; import { parseFeedView, type FeedView } from "../lib/feedView"; -import { LS, readAccount, writeAccount } from "../lib/storage"; +import { LS } from "../lib/storage"; +import { usePref } from "../lib/usePref"; // Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed // page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App. // Split State + Actions contexts, mirroring the other providers; composed in main.tsx around . // -// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs) -// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider -// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial -// render synchronously; the passive qk.me() observer adopts the server value once the account resolves. +// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is +// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror +// that seeds the first render synchronously) so this provider is just the two contexts over it. interface FeedViewActions { setView: (v: FeedView) => void; @@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions { } export function FeedViewProvider({ children }: { children: ReactNode }) { - const [view, setViewState] = useState(() => - parseFeedView(readAccount(LS.feedView, null)), - ); - // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). - const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); - - // A user pick persists both to the localStorage mirror (instant reload) and to the server. - const setView = useCallback((next: FeedView) => { - setViewState(next); - writeAccount(LS.feedView, next); - api.savePrefs({ view: next }).catch(() => {}); - }, []); - - // Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs - // (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45 - // "grid" / "list" values forward. - useEffect(() => { - const prefs = me?.preferences; - if (!prefs) return; - const adopted = parseFeedView(prefs.view); - setViewState(adopted); - writeAccount(LS.feedView, adopted); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [me?.id]); + // parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values). + const [view, setView] = usePref("view", "cards", { + localKey: LS.feedView, + parse: parseFeedView, + }); const actions = useMemo(() => ({ setView }), [setView]); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 4360b43f..25fe79bc 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useNavigationActions } from "./NavigationProvider"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; +import { usePref } from "../lib/usePref"; import { AlertTriangle, ArrowLeft, @@ -481,36 +482,18 @@ export default function PlayerModal({ p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true); }; - // Auto-advance + loop are persistent per-user settings. Read the current values from the cached - // `me`, mirror them in local state for instant UI, and write both to the server + cache on change - // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler. - const savedPrefs = useMemo( - () => - (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { - playerAutoAdvance?: AutoMode; - playerLoop?: LoopMode; - }, - [qc], - ); - const [autoMode, setAutoMode] = useState(savedPrefs.playerAutoAdvance ?? "off"); - const [loopMode, setLoopMode] = useState(savedPrefs.playerLoop ?? "off"); + // Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on + // login) so they apply to every player and survive reloads. The player mounts after `me` is cached, + // so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler. + const [autoMode, setAutoMode] = usePref("playerAutoAdvance", "off"); + const [loopMode, setLoopMode] = usePref("playerLoop", "off"); const modeRef = useRef({ autoMode, loopMode }); modeRef.current = { autoMode, loopMode }; - const persistPref = (patch: Record) => { - api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => - m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, - ); - }; const cycleAuto = () => { - const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!; - setAutoMode(next); - persistPref({ playerAutoAdvance: next }); + setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!); }; const cycleLoop = () => { - const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!; - setLoopMode(next); - persistPref({ playerLoop: next }); + setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!); }; const replayCurrent = () => { const p = playerRef.current; diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 0ab08d91..328bf66c 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -1,10 +1,8 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; -import { qk } from "./queryKeys"; import { useTranslation } from "react-i18next"; -import { useQueryClient } from "@tanstack/react-query"; import { SlidersHorizontal } from "lucide-react"; import { useDismiss } from "./useDismiss"; -import { api } from "./api"; +import { usePref, useSavePref } from "./usePref"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -12,42 +10,21 @@ import { api } from "./api"; // customize menu that toggles them. export function useDetailPrefs() { - const qc = useQueryClient(); - const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? - {}) as { - plexInfoArtBg?: boolean; - plexInfoCast?: boolean; - plexInfoRelated?: boolean; - }; - const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); - const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); - const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false); - const save = (patch: Record) => { - api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(qk.me(), (m) => - m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, - ); - }; + // Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON: + // undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so + // usePref seeds from the cache (no localStorage mirror). + const [artBg, setArtBg] = usePref("plexInfoArtBg", true); + const [showCast, setShowCast] = usePref("plexInfoCast", true); + const [showRelated, setShowRelated] = usePref("plexInfoRelated", true); + const savePref = useSavePref(); return { artBg, showCast, showRelated, - savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources) - toggleArtBg: () => { - const next = !artBg; - setArtBg(next); - save({ plexInfoArtBg: next }); - }, - toggleCast: () => { - const next = !showCast; - setShowCast(next); - save({ plexInfoCast: next }); - }, - toggleRelated: () => { - const next = !showRelated; - setShowRelated(next); - save({ plexInfoRelated: next }); - }, + savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources) + toggleArtBg: () => setArtBg(!artBg), + toggleCast: () => setShowCast(!showCast), + toggleRelated: () => setShowRelated(!showRelated), }; } diff --git a/frontend/src/lib/usePref.dom.test.ts b/frontend/src/lib/usePref.dom.test.ts new file mode 100644 index 00000000..e20831da --- /dev/null +++ b/frontend/src/lib/usePref.dom.test.ts @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +// +// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache / +// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror), +// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the +// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy. +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; + +const { store, savePrefs } = vi.hoisted(() => ({ + store: new Map(), + savePrefs: vi.fn(() => Promise.resolve({})), +})); + +vi.mock("./storage", () => ({ + readAccount: (base: string, fallback: unknown) => { + const raw = store.get(base); + return raw == null ? fallback : JSON.parse(raw); + }, + writeAccount: (base: string, value: unknown) => { + store.set(base, JSON.stringify(value)); + }, +})); +vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } })); + +import { usePref, useSavePref } from "./usePref"; +import { qk } from "./queryKeys"; + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} +function seedMe(qc: QueryClient, preferences: Record, id = 1) { + qc.setQueryData(qk.me(), { id, preferences }); +} +function wrapperFor(qc: QueryClient) { + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); +} + +describe("usePref", () => { + beforeEach(() => { + store.clear(); + savePrefs.mockClear(); + }); + + it("seeds from the default when neither server nor mirror has the key", () => { + const qc = makeClient(); + const { result } = renderHook(() => usePref("view", "cards"), { + wrapper: wrapperFor(qc), + }); + expect(result.current[0]).toBe("cards"); + }); + + it("seeds from the server (me cache) when present", () => { + const qc = makeClient(); + seedMe(qc, { view: "rows" }); + const { result } = renderHook(() => usePref("view", "cards"), { + wrapper: wrapperFor(qc), + }); + expect(result.current[0]).toBe("rows"); + }); + + it("seeds from the localStorage mirror when there is no server value", () => { + const qc = makeClient(); + store.set("siftlode.feedView", JSON.stringify("tiles")); + const { result } = renderHook( + () => usePref("view", "cards", { localKey: "siftlode.feedView" }), + { wrapper: wrapperFor(qc) }, + ); + expect(result.current[0]).toBe("tiles"); + }); + + it("prefers the server value over the mirror", () => { + const qc = makeClient(); + seedMe(qc, { view: "rows" }); + store.set("siftlode.feedView", JSON.stringify("tiles")); + const { result } = renderHook( + () => usePref("view", "cards", { localKey: "siftlode.feedView" }), + { wrapper: wrapperFor(qc) }, + ); + expect(result.current[0]).toBe("rows"); + }); + + it("applies parse to a raw stored value", () => { + const qc = makeClient(); + seedMe(qc, { view: "GRID" }); + const { result } = renderHook( + () => usePref("view", "cards", { parse: (r) => String(r).toLowerCase() }), + { wrapper: wrapperFor(qc) }, + ); + expect(result.current[0]).toBe("grid"); + }); + + it("on set: updates state, writes the server, merges the me cache, and the mirror", () => { + const qc = makeClient(); + seedMe(qc, {}); + const { result } = renderHook( + () => usePref("view", "cards", { localKey: "siftlode.feedView" }), + { wrapper: wrapperFor(qc) }, + ); + act(() => result.current[1]("rows")); + expect(result.current[0]).toBe("rows"); + expect(savePrefs).toHaveBeenCalledWith({ view: "rows" }); + expect( + (qc.getQueryData(qk.me()) as { preferences: Record }).preferences.view, + ).toBe("rows"); + expect(store.get("siftlode.feedView")).toBe(JSON.stringify("rows")); + }); + + it("does not write a localStorage mirror when no localKey is given", () => { + const qc = makeClient(); + seedMe(qc, {}); + const { result } = renderHook(() => usePref("playerLoop", "off"), { + wrapper: wrapperFor(qc), + }); + act(() => result.current[1]("all")); + expect(store.size).toBe(0); + expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" }); + }); + + it("adopts the server value when the account id changes (login / switch)", async () => { + const qc = makeClient(); + seedMe(qc, { view: "cards" }, 1); + const { result } = renderHook(() => usePref("view", "cards"), { + wrapper: wrapperFor(qc), + }); + expect(result.current[0]).toBe("cards"); + // A different account resolves. The passive me observer notifies asynchronously (react-query's + // notifyManager batches), so wait for the re-render — the same path the real App drives on login. + act(() => seedMe(qc, { view: "tiles" }, 2)); + await waitFor(() => expect(result.current[0]).toBe("tiles")); + }); + + describe("debounceMs", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("coalesces rapid sets into one PUT after the delay", () => { + const qc = makeClient(); + seedMe(qc, {}); + const { result } = renderHook(() => usePref("vol", 0, { debounceMs: 300 }), { + wrapper: wrapperFor(qc), + }); + act(() => { + result.current[1](10); + result.current[1](20); + result.current[1](30); + }); + // Local state is instant; the server write is deferred. + expect(result.current[0]).toBe(30); + expect(savePrefs).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(300)); + expect(savePrefs).toHaveBeenCalledTimes(1); + expect(savePrefs).toHaveBeenCalledWith({ vol: 30 }); + }); + }); +}); + +describe("useSavePref", () => { + beforeEach(() => savePrefs.mockClear()); + + it("persists an arbitrary patch and merges it into the me cache", () => { + const qc = makeClient(); + seedMe(qc, { a: 1 }); + const { result } = renderHook(() => useSavePref(), { wrapper: wrapperFor(qc) }); + act(() => result.current({ plexHiddenStripSources: ["x"] })); + expect(savePrefs).toHaveBeenCalledWith({ plexHiddenStripSources: ["x"] }); + const prefs = (qc.getQueryData(qk.me()) as { preferences: Record }) + .preferences; + expect(prefs).toEqual({ a: 1, plexHiddenStripSources: ["x"] }); + }); +}); diff --git a/frontend/src/lib/usePref.ts b/frontend/src/lib/usePref.ts new file mode 100644 index 00000000..6d7a0776 --- /dev/null +++ b/frontend/src/lib/usePref.ts @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "./queryKeys"; +import { api } from "./api"; +import type { Me } from "./api/account"; +import { readAccount, writeAccount } from "./storage"; + +// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to +// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted +// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its +// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache, +// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds +// all three into one hook so a new server-synced pref is a one-liner. +// +// Two mirror strategies, unified here (pick via `localKey`): +// • Providers mounted ABOVE (feed view, panel layouts, rail-collapse flags) render before +// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on +// reload — pass `localKey`. +// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they +// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`. +// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other +// consumers reading the same key (e.g. a second player instance) see the change at once. + +export interface UsePrefOptions { + /** Also mirror to per-account localStorage under this base key, and seed synchronously from it on + * first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */ + localKey?: string; + /** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The + * local state + cache still update instantly; only the network write is deferred. */ + debounceMs?: number; + /** Normalize a raw stored value (from the server payload or the localStorage mirror) into T — e.g. + * mapping legacy values forward, or clamping. Defaults to identity. Must be idempotent. */ + parse?: (raw: unknown) => T; +} + +function readServerPref(me: Me | null | undefined, key: string): unknown { + return me?.preferences?.[key]; +} + +/** The raw preference persister: save an arbitrary partial preferences patch to the server and merge + * it into the `me` cache optimistically (so readers see it without a refetch). For dynamic or + * multi-key prefs that don't map to a single stateful usePref call — e.g. a variable set of toggles + * (PlexInfo's per-collection strip-source hiding). Prefer usePref for a fixed, stateful key. */ +export function useSavePref(): (patch: Record) => void { + const qc = useQueryClient(); + return useCallback( + (patch: Record) => { + api.savePrefs(patch).catch(() => {}); + qc.setQueryData(qk.me(), (m) => + m ? { ...m, preferences: { ...m.preferences, ...patch } } : m, + ); + }, + [qc], + ); +} + +/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with + * automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */ +export function usePref( + serverKey: string, + defaultValue: T, + options: UsePrefOptions = {}, +): [T, (next: T) => void] { + const { localKey, debounceMs, parse } = options; + const qc = useQueryClient(); + // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). + const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); + + // Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which + // would churn the memoized setter and the adopt effect). parse must be idempotent anyway. + const parseRef = useRef(parse); + parseRef.current = parse; + const norm = useCallback((raw: unknown): T => { + const p = parseRef.current; + return p ? p(raw) : (raw as T); + }, []); + + const seed = useCallback((): T => { + const server = readServerPref(qc.getQueryData(qk.me()), serverKey); + if (server !== undefined) return norm(server); + if (localKey) return norm(readAccount(localKey, defaultValue)); + return defaultValue; + // defaultValue / serverKey / localKey are stable for a given call site. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qc, serverKey, localKey, norm]); + + const [value, setValue] = useState(seed); + + const saveTimer = useRef(undefined); + useEffect(() => () => window.clearTimeout(saveTimer.current), []); + + const set = useCallback( + (next: T) => { + setValue(next); + if (localKey) writeAccount(localKey, next); + // Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect) + // see the new value without a refetch. Merges into preferences, mirroring the server PUT. + qc.setQueryData(qk.me(), (m) => + m ? { ...m, preferences: { ...m.preferences, [serverKey]: next } } : m, + ); + const payload = { [serverKey]: next }; + // savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state + + // cache already reflect the change (idempotent PUT retries a transient blip). + if (debounceMs) { + window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + api.savePrefs(payload).catch(() => {}); + }, debounceMs); + } else { + api.savePrefs(payload).catch(() => {}); + } + }, + [qc, serverKey, localKey, debounceMs], + ); + + // Adopt the server value when the account becomes known / changes (login, account switch). Guarded on + // the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key → + // keep the current (seeded / user-set) value rather than clobbering it with the default. + const prevId = useRef(me?.id); + useEffect(() => { + if (me?.id === prevId.current) return; + prevId.current = me?.id; + const server = readServerPref(me, serverKey); + if (server === undefined) return; + const val = norm(server); + setValue(val); + if (localKey) writeAccount(localKey, val); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me?.id]); + + return [value, set]; +} From 8bfc01684fe41f9a757cad71528dcafa027c3ec0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 04:47:36 +0200 Subject: [PATCH 02/11] =?UTF-8?q?feat(nav):=20full=20bookmarkable=20URL=20?= =?UTF-8?q?model=20=E2=80=94=20retire=20minimalist-URL=20+=20Share-view=20?= =?UTF-8?q?(E4=20S2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The query string is now the source of truth for the current view, so every URL is bookmarkable/shareable. Reverses the early minimalist-URL rule (user's call). - urlState: urlWithParams (read-modify-write delta on the live query, each provider owns its keys) + clearParams (strip only transient auth params) replace the whole- query stripUrlParams; shareUrl deleted; filterParamUpdates helper. - NavigationProvider: page + ?chan mirrored to the address bar (pushState URL arg; history.state mechanics untouched); loadInitialPage resolves deep links (?v->plex, ?pl->playlists, filter/?chan->feed, bare->stored landing). - FeedFiltersProvider: feed filters mirrored to the URL, gated on page===feed. - useHistorySubview: optional SubviewUrl mapping; PlexBrowse mirrors the sub-view to ?v=: (show/season/info/person/playlist; grid+player stay ephemeral). - PlaylistsProvider: selected playlist mirrored to ?pl on the Playlists page. - App: the 5 stripUrlParams calls become targeted clearParams (link/oauth/admin/ access/verify/reset); the share-link hydrate effect removed. - Retire the "Share view" feature: Sidebar + SavedViewsWidget buttons + i18n keys. Browser tab title stays the short module/channel name (document.title untouched) — the query never leaks into it, per the user's request. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest (urlState.dom.test +9). No browser-E2E yet (most visual sprint — flagged for the epic-end review). --- frontend/src/App.tsx | 33 ++--- .../src/components/FeedFiltersProvider.tsx | 26 +++- .../src/components/NavigationProvider.tsx | 68 +++++++--- frontend/src/components/PlaylistsProvider.tsx | 27 +++- frontend/src/components/PlexBrowse.tsx | 48 ++++++- frontend/src/components/SavedViewsWidget.tsx | 24 +--- frontend/src/components/Sidebar.tsx | 21 +-- frontend/src/i18n/locales/en/sidebar.json | 3 - frontend/src/i18n/locales/en/views.json | 2 - frontend/src/i18n/locales/hu/sidebar.json | 3 - frontend/src/i18n/locales/hu/views.json | 2 - frontend/src/lib/history.ts | 33 ++++- frontend/src/lib/urlState.dom.test.ts | 124 ++++++++++++++++++ frontend/src/lib/urlState.ts | 63 ++++++--- 14 files changed, 362 insertions(+), 115 deletions(-) create mode 100644 frontend/src/lib/urlState.dom.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13a21521..5af82e6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; -import { hasFilterParams, stripUrlParams } from "./lib/urlState"; +import { clearParams } from "./lib/urlState"; import type { Page } from "./lib/urlState"; import { loadLayout, @@ -103,17 +103,8 @@ export default function App() { localKey: LS.playlistsCollapsed, parse: Boolean, }); - // Filters live in localStorage, not the address bar. If we arrived via a "Share view" - // link, its params have already hydrated the initial state — persist them and strip the - // query so the URL stays clean from here on. - useEffect(() => { - const params = new URLSearchParams(window.location.search); - if (hasFilterParams(params) || params.has("page")) { - // A share link's filters are held in state; they're persisted to the account's key by the - // post-login effect (which knows the account id). Here we only clean the address bar. - stripUrlParams(); - } - }, []); + // (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and + // NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.) const queryClient = useQueryClient(); // First-run install gate: a fresh instance reports configured=false and runs in setup mode. @@ -145,11 +136,11 @@ export default function App() { if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); }, [meQuery.data?.id]); - // Once signed in, drop any leftover query string from the pre-login flow (e.g. - // ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the - // address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params. + // Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify, + // ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the + // URL. Gated on auth so the logged-out Welcome page still reads its params first. useEffect(() => { - if (meQuery.data) stripUrlParams(); + if (meQuery.data) clearParams("access", "verify", "reset"); }, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps // Any request that 401s means the session ended server-side. If we were signed in (cached @@ -181,7 +172,7 @@ export default function App() { const key = result === "conflict" || result === "mismatch" ? result : "error"; notify({ level: "error", message: t(`settings.account.googleLink.${key}`) }); } - stripUrlParams(); + clearParams("link"); }, []); // eslint-disable-line react-hooks/exhaustive-deps // A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was @@ -190,12 +181,12 @@ export default function App() { useEffect(() => { if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return; notify({ level: "info", message: t("settings.account.googleCancelled") }); - stripUrlParams(); + clearParams("oauth"); }, []); // eslint-disable-line react-hooks/exhaustive-deps // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to - // the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't - // drop it; act once `me` is known, and only for an admin (others just land on their default page). + // the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once + // `me` is known, and only for an admin (others just land on their default page). const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin")); useEffect(() => { if (adminDeepLink !== "access-requests" || !meQuery.data) return; @@ -203,7 +194,7 @@ export default function App() { focusAccessRequestsTab(); setPage("users"); } - stripUrlParams(); + clearParams("admin"); }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps // On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 6ce59216..aca62f05 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -10,8 +10,14 @@ import { import { qk } from "../lib/queryKeys"; import { useQuery } from "@tanstack/react-query"; import { api, getActiveAccount, type FeedFilters } from "../lib/api"; -import { hasFilterParams, paramsToFilters } from "../lib/urlState"; +import { + filterParamUpdates, + hasFilterParams, + paramsToFilters, + urlWithParams, +} from "../lib/urlState"; import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage"; +import { useNavigation } from "./NavigationProvider"; // Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App // god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters` @@ -90,8 +96,8 @@ export function useFeedFiltersActions(): FeedFiltersActions { export function FeedFiltersProvider({ children }: { children: ReactNode }) { const [filters, setFiltersState] = useState(loadInitialFilters); - // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) - // — such filters win over the account's stored view and are persisted to the account instead. + // Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured + // once at mount — such filters win over the account's stored view and are persisted to the account. const [initialHadUrlFilters] = useState(() => hasFilterParams(new URLSearchParams(window.location.search)), ); @@ -108,6 +114,20 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) { if (key) writeJSON(key, next); }, []); + // Mirror the feed filters into the address bar so every feed view is bookmarkable (replaceState — a + // filter tweak is not a new history entry). Gated on page===feed: on other pages the filter keys are + // cleared from the URL (localStorage still holds them; they re-mirror on return to the feed). The + // channel detail page keeps its own isolated filters, so those never reach here. urlWithParams + // preserves the nav params (?page/?chan/?v/?pl) the other providers own. + const { page } = useNavigation(); + useEffect(() => { + window.history.replaceState( + window.history.state, + "", + urlWithParams(filterParamUpdates(page === "feed" ? filters : null)), + ); + }, [filters, page]); + // Load THIS account's own filters when the account first resolves or changes (login / switch / // add). Filters are per-account localStorage state, so another account's last view (incl. its // default saved view) must never carry over. A share link's filters (captured at mount) win and diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index c96ad3be..c8e4cd78 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -9,7 +9,7 @@ import { type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; -import { isPage, readPage, type Page } from "../lib/urlState"; +import { hasFilterParams, isPage, readPage, urlWithParams, type Page } from "../lib/urlState"; import { pageTitleKey } from "../lib/pageMeta"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; @@ -52,6 +52,12 @@ export interface NavigationActions { function loadInitialPage(): Page { const params = new URLSearchParams(window.location.search); if (params.has("page")) return readPage(); + // A view-specific param implies its page, so a bookmarked deep link lands on the right module even + // without an explicit ?page. Feed filters (or a channel overlay) imply the feed. + if (params.has("v")) return "plex"; + if (params.has("pl")) return "playlists"; + if (hasFilterParams(params) || params.has("chan")) return "feed"; + // A bare URL → the account's stored landing page. const stored = getAccountRaw(PAGE_KEY); return isPage(stored) ? stored : "feed"; } @@ -88,10 +94,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) { return st?._yt && st?._ytScrape ? (st._yt as string) : null; }); // The open channel page rides in history.state (_chan = id, _chanName = a name to show before the - // detail loads), so Back closes it and a reload restores it (unlike ytSearch, which is dropped). + // detail loads) AND is mirrored to the address bar as ?chan=, so Back closes it, a reload restores + // it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app + // entry reads history.state._chan; a fresh bookmark has only the ?chan param. const [channelView, setChannelView] = useState(() => { - const c = window.history.state?._chan as string | undefined; - return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null; + const st = window.history.state; + const id = + (st?._chan as string | undefined) ?? + new URLSearchParams(window.location.search).get("chan") ?? + undefined; + return id ? { id, name: (st?._chanName as string) ?? undefined } : null; }); // Refs so the STABLE action callbacks can read the latest state without depending on it — a dep @@ -116,8 +128,13 @@ export function NavigationProvider({ children }: { children: ReactNode }) { setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative // Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel // (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page, - // rather than being orphaned from history so Back can't close it. - window.history.replaceState({ ...(window.history.state || {}), sfPage: stored }, ""); + // rather than being orphaned from history so Back can't close it. Mirror the corrected page to the + // address bar too (urlWithParams keeps any ?chan/filter params already there). + window.history.replaceState( + { ...(window.history.state || {}), sfPage: stored }, + "", + urlWithParams({ page: stored === "feed" ? null : stored }), + ); }); const setPage = useCallback( @@ -130,9 +147,15 @@ export function NavigationProvider({ children }: { children: ReactNode }) { setPageState(next); setAccountRaw(PAGE_KEY, next); // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the - // app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over - // _yt/_chan markers) so each page starts at its root. - window.history.pushState({ sfPage: next }, ""); + // app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and + // the address bar reflects the page (feed is implicit) while clearing the view-specific params + // (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by + // FeedFiltersProvider) are left intact. + window.history.pushState( + { sfPage: next }, + "", + urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }), + ); }, [markDirty], ); @@ -143,13 +166,20 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const openChannel = useCallback((id: string, name?: string) => { setChannelView({ id, name }); const st = window.history.state || {}; - if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, ""); - else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, ""); + const url = urlWithParams({ chan: id }); + if (st._chan) + window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); + else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url); }, []); const closeChannel = useCallback(() => { if (window.history.state?._chan) window.history.back(); - else setChannelView(null); + else { + // No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and + // drop the ?chan param from the address bar. + setChannelView(null); + window.history.replaceState(window.history.state, "", urlWithParams({ chan: null })); + } }, []); const enterYtSearch = useCallback((q: string) => { @@ -189,19 +219,23 @@ export function NavigationProvider({ children }: { children: ReactNode }) { document.title = label ? `${label} · ${brand}` : brand; }, [page, channelView, i18n.language, t]); - // In-app Back/Forward: stamp the initial entry with the current page, then sync state from - // history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a - // later query-string strip in App.) + // In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar), + // then sync state from history.state on popstate — the browser restores each entry's URL for us, so + // the popstate handler only needs to read history.state. useEffect(() => { // A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape // so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName // are always kept so a channel page survives F5. const st = window.history.state || {}; + // Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the + // stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any + // ?chan / ?v / ?pl / filter params the loaders already read. + const url = urlWithParams({ page: page === "feed" ? null : page }); if (st._yt && st._ytScrape) { - window.history.replaceState({ ...st, sfPage: page }, ""); + window.history.replaceState({ ...st, sfPage: page }, "", url); } else { const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st; - window.history.replaceState({ ...rest, sfPage: page }, ""); + window.history.replaceState({ ...rest, sfPage: page }, "", url); } function onPop(e: PopStateEvent) { const p = (e.state?.sfPage as Page) ?? "feed"; diff --git a/frontend/src/components/PlaylistsProvider.tsx b/frontend/src/components/PlaylistsProvider.tsx index 25ba37d9..dcea3109 100644 --- a/frontend/src/components/PlaylistsProvider.tsx +++ b/frontend/src/components/PlaylistsProvider.tsx @@ -1,6 +1,16 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; +import { urlWithParams } from "../lib/urlState"; +import { useNavigation } from "./NavigationProvider"; // Owns the Playlists module's shared local state — the rail's name filter and the selected playlist // — lifted out of the App god component so the header search box, the PlaylistsRail, and the @@ -37,6 +47,9 @@ export function usePlaylistsActions(): PlaylistsActions { } function readSelectedId(): number | null { + // A ?pl bookmark wins over the stored selection so a pasted link opens that playlist. + const fromUrl = new URLSearchParams(window.location.search).get("pl"); + if (fromUrl != null && Number.isFinite(Number(fromUrl))) return Number(fromUrl); const s = getAccountRaw(LS.playlist); return s ? Number(s) : null; } @@ -59,6 +72,18 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { [markDirty], ); + // Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere + // (the localStorage copy still restores it on return). replaceState — selecting isn't a new history + // entry — and urlWithParams keeps ?page and the other nav params intact. + const { page } = useNavigation(); + useEffect(() => { + window.history.replaceState( + window.history.state, + "", + urlWithParams({ pl: page === "playlists" && selectedId != null ? String(selectedId) : null }), + ); + }, [page, selectedId]); + const state = useMemo(() => ({ search, selectedId }), [search, selectedId]); const actions = useMemo( () => ({ setSearch, setSelectedId }), diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 7c8440fc..4ec3fc5a 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -54,7 +54,7 @@ import { import { formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; import { useDebounced } from "../lib/useDebounced"; -import { useHistorySubview } from "../lib/history"; +import { useHistorySubview, type SubviewUrl } from "../lib/history"; import { DetailCustomizeMenu, Filterable, @@ -83,6 +83,50 @@ type Sub = | { kind: "person"; name: string } | { kind: "playlist"; id: number }; +// Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page +// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person +// name is the whole tail after the first ":", so a name containing ":" round-trips. +const PLEX_SUB_URL: SubviewUrl = { + key: "v", + encode: (v) => { + switch (v.kind) { + case "show": + return `show:${v.id}`; + case "season": + return `season:${v.showId}:${v.seasonId}`; + case "info": + return `info:${v.id}`; + case "person": + return `person:${v.name}`; + case "playlist": + return `playlist:${v.id}`; + default: + return null; // grid + the ephemeral player are not URL-mirrored + } + }, + decode: (raw) => { + const i = raw.indexOf(":"); + const kind = i === -1 ? raw : raw.slice(0, i); + const arg = i === -1 ? "" : raw.slice(i + 1); + switch (kind) { + case "show": + return arg ? { kind: "show", id: arg } : null; + case "info": + return arg ? { kind: "info", id: arg } : null; + case "person": + return arg ? { kind: "person", name: arg } : null; + case "playlist": + return arg && Number.isFinite(Number(arg)) ? { kind: "playlist", id: Number(arg) } : null; + case "season": { + const [showId, seasonId] = arg.split(":"); + return showId && seasonId ? { kind: "season", showId, seasonId } : null; + } + default: + return null; + } + }, +}; + // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a // show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the // search term comes from the shared top Header search box (q). A show drill-down rides history @@ -135,7 +179,7 @@ export default function PlexBrowse() { const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. const fieldState = healPlexField(field, scope); - const sub = useHistorySubview({ kind: "grid" }); + const sub = useHistorySubview({ kind: "grid" }, PLEX_SUB_URL); // Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then // clear the signal so it doesn't re-open. diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index 44d6b6dd..04caa02e 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { BookmarkPlus, Check, GripVertical, Pencil, Share2, Star, Trash2, X } from "lucide-react"; +import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react"; import { closestCenter, DndContext, @@ -19,10 +19,8 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; -import { filtersToParams, shareUrl } from "../lib/urlState"; -import { notify } from "../lib/notifications"; +import { filtersToParams } from "../lib/urlState"; import { notifyActionError } from "../lib/actionErrors"; -import { copyText } from "../lib/clipboard"; import { accountKey, LS, writeJSON } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; @@ -131,14 +129,6 @@ export default function SavedViewsWidget({ await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); } - async function share(v: SavedView) { - if (await copyText(shareUrl(asFilters(v.filters)))) { - notify({ level: "success", message: t("views.shareCopied") }); - } else { - notify({ level: "warning", message: t("sidebar.shareFailed") }); - } - } - async function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; @@ -177,7 +167,6 @@ export default function SavedViewsWidget({ onRename={(name) => rename(v.id, name)} onCancelRename={() => setRenameId(null)} onToggleDefault={() => toggleDefault(v)} - onShare={() => share(v)} onDelete={() => remove(v)} /> ))} @@ -239,7 +228,6 @@ function ViewRow({ onRename, onCancelRename, onToggleDefault, - onShare, onDelete, }: { view: SavedView; @@ -250,7 +238,6 @@ function ViewRow({ onRename: (name: string) => void; onCancelRename: () => void; onToggleDefault: () => void; - onShare: () => void; onDelete: () => void; }) { const { t } = useTranslation(); @@ -339,13 +326,6 @@ function ViewRow({ > - - ); diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index ea21751c..8479f686 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -28,9 +28,6 @@ "reshuffle": "Reshuffle", "noMatchingTags": "No matching tags here", "manageTags": "Manage", - "shareView": "Copy a link to this view", - "shareCopied": "View link copied to clipboard", - "shareFailed": "Couldn't copy the link", "widget": { "savedviews": "Saved views", "show": "Show", diff --git a/frontend/src/i18n/locales/en/views.json b/frontend/src/i18n/locales/en/views.json index a2e41742..8a8b835b 100644 --- a/frontend/src/i18n/locales/en/views.json +++ b/frontend/src/i18n/locales/en/views.json @@ -7,8 +7,6 @@ "apply": "Apply this view", "makeDefault": "Set as default (loads on start)", "isDefault": "Default view — loads on start", - "share": "Copy a link to this view", - "shareCopied": "View link copied", "rename": "Rename", "delete": "Delete", "deleteConfirm": "Delete the saved view “{{name}}”?", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 6b8c01fa..5ed4f472 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -28,9 +28,6 @@ "reshuffle": "Újrakeverés", "noMatchingTags": "Nincs ide illő címke", "manageTags": "Kezelés", - "shareView": "Hivatkozás másolása erre a nézetre", - "shareCopied": "Nézet-hivatkozás a vágólapra másolva", - "shareFailed": "Nem sikerült a hivatkozás másolása", "widget": { "savedviews": "Mentett nézetek", "show": "Megjelenítés", diff --git a/frontend/src/i18n/locales/hu/views.json b/frontend/src/i18n/locales/hu/views.json index d95f1c05..018a9138 100644 --- a/frontend/src/i18n/locales/hu/views.json +++ b/frontend/src/i18n/locales/hu/views.json @@ -7,8 +7,6 @@ "apply": "Nézet alkalmazása", "makeDefault": "Beállítás alapértelmezettként (indításkor betölt)", "isDefault": "Alapértelmezett nézet — indításkor betölt", - "share": "Hivatkozás másolása a nézethez", - "shareCopied": "Nézet hivatkozása a vágólapra másolva", "rename": "Átnevezés", "delete": "Törlés", "deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?", diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index ba1289fe..4cfe0460 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -10,15 +10,41 @@ // // setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root. import { useEffect, useRef, useState } from "react"; +import { urlWithParams } from "./urlState"; // --- Sub-views (value carried in history.state._sub) --------------------------------------- -export function useHistorySubview(root: T): { +/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is + * bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */ +export interface SubviewUrl { + /** The query key this sub-view mirrors to (e.g. "v" for the Plex sub-view). */ + key: string; + /** Serialize a sub-view to the param value, or null for views not mirrored (root / ephemeral ones). */ + encode: (view: T) => string | null; + /** Parse a param value back to a sub-view, or null if it isn't a valid one. */ + decode: (raw: string) => T | null; +} + +export function useHistorySubview( + root: T, + url?: SubviewUrl, +): { view: T; open: (next: T) => void; back: () => void; } { - const [view, setView] = useState(() => (window.history.state?._sub as T) ?? root); + const [view, setView] = useState(() => { + // Reload of an in-app entry: history.state._sub. Fresh bookmark: decode the ? param. + const fromState = window.history.state?._sub as T | undefined; + if (fromState != null) return fromState; + if (url) { + const raw = new URLSearchParams(window.location.search).get(url.key); + const decoded = raw != null ? url.decode(raw) : null; + if (decoded != null) return decoded; + } + return root; + }); useEffect(() => { + // Back/Forward restores the sub-view from history.state (the browser restores each entry's URL too). const onPop = () => setView((window.history.state?._sub as T) ?? root); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); @@ -28,7 +54,8 @@ export function useHistorySubview(root: T): { view, open: (next: T) => { setView(next); - window.history.pushState({ ...window.history.state, _sub: next }, ""); + const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; + window.history.pushState({ ...window.history.state, _sub: next }, "", href); }, back: () => window.history.back(), }; diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts new file mode 100644 index 00000000..094ef348 --- /dev/null +++ b/frontend/src/lib/urlState.dom.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +// +// The address-bar helpers that back the URL model: urlWithParams (read-modify-write on the live query so +// each provider owns its own key without clobbering the others), clearParams (strip only transient +// auth/one-time keys, never the nav/filter ones), and filterParamUpdates (feed filters → set/delete +// delta, or clear-all). The round-trip through filtersToParams/paramsToFilters is exercised too. +import { describe, it, expect, beforeEach } from "vitest"; +import { + urlWithParams, + clearParams, + filterParamUpdates, + filtersToParams, + paramsToFilters, +} from "./urlState"; +import type { FeedFilters } from "./api"; + +function setLocation(search: string) { + window.history.replaceState(null, "", `/app${search}`); +} +function queryOf(url: string): URLSearchParams { + return new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : ""); +} + +const DEFAULTS: FeedFilters = { + tags: [], + tagMode: "or", + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +describe("urlWithParams", () => { + beforeEach(() => setLocation("")); + + it("sets a key while preserving the pathname and existing params", () => { + setLocation("?page=plex"); + const url = urlWithParams({ v: "show:1" }); + expect(url.startsWith("/app?")).toBe(true); + const q = queryOf(url); + expect(q.get("v")).toBe("show:1"); + expect(q.get("page")).toBe("plex"); // untouched + }); + + it("deletes a key on a null value and leaves the others", () => { + setLocation("?page=plex&v=show:1&q=react"); + const q = queryOf(urlWithParams({ v: null })); + expect(q.has("v")).toBe(false); + expect(q.get("page")).toBe("plex"); + expect(q.get("q")).toBe("react"); + }); + + it("applies a mix of sets and deletes in one call", () => { + setLocation("?page=plex&v=show:1&chan=UC1"); + const q = queryOf(urlWithParams({ page: null, chan: null, v: null, pl: null })); + expect([...q.keys()]).toEqual([]); // page switch resets the view-specific keys + }); + + it("returns a bare path when the query empties out", () => { + setLocation("?page=channels"); + expect(urlWithParams({ page: null })).toBe("/app"); + }); +}); + +describe("clearParams", () => { + beforeEach(() => setLocation("")); + + it("removes only the named transient keys, keeping nav/filter params", () => { + setLocation("?link=ok&page=plex&v=show:1&q=react"); + clearParams("link", "oauth", "admin", "access", "verify", "reset"); + const q = new URLSearchParams(window.location.search); + expect(q.has("link")).toBe(false); + expect(q.get("page")).toBe("plex"); + expect(q.get("v")).toBe("show:1"); + expect(q.get("q")).toBe("react"); + }); + + it("is a no-op (no history write) when none of the keys are present", () => { + setLocation("?page=plex"); + const before = window.location.search; + clearParams("link", "oauth"); + expect(window.location.search).toBe(before); + }); +}); + +describe("filterParamUpdates", () => { + it("maps non-default filters to values and defaults to null (delete)", () => { + const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" }; + const upd = filterParamUpdates(f); + expect(upd.q).toBe("react"); + expect(upd.tags).toBe("3,7"); + expect(upd.show).toBe("watched"); + expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared + expect(upd.scope).toBeNull(); + }); + + it("clears ALL filter keys when passed null (leaving the feed)", () => { + const upd = filterParamUpdates(null); + for (const v of Object.values(upd)) expect(v).toBeNull(); + // includes every recognized filter key + expect(Object.keys(upd)).toEqual( + expect.arrayContaining(["q", "tags", "show", "sort", "scope"]), + ); + }); + + it("round-trips through the URL (filters → params → filters)", () => { + const f: FeedFilters = { + ...DEFAULTS, + q: "hi", + tags: [1, 2], + scope: "all", + includeShorts: true, + }; + const back = paramsToFilters(filtersToParams(f), DEFAULTS); + expect(back.q).toBe("hi"); + expect(back.tags).toEqual([1, 2]); + expect(back.scope).toBe("all"); + expect(back.includeShorts).toBe(true); + }); +}); diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 4af6dc15..c432c2ed 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -1,7 +1,9 @@ -// Serialize feed filters to/from a compact, human-readable URL query string. Filters are -// NOT kept in the address bar during normal use (localStorage is the single source of -// truth); this serializer powers the explicit "Share view" link and the one-time hydration -// when someone opens such a link. Only non-default values are emitted to keep URLs clean. +// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the +// address-bar URL for the whole app's navigation. The query string IS the source of truth for the +// current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is +// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is +// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays +// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. import type { FeedFilters } from "./api"; const KEYS = [ @@ -46,6 +48,17 @@ export function filtersToParams(f: FeedFilters): URLSearchParams { return p; } +/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted + * value, or null to delete it (so switching a filter back to its default clears the key). Pass `null` + * to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them. + * Feed the result into urlWithParams(). */ +export function filterParamUpdates(f: FeedFilters | null): Record { + const p = f ? filtersToParams(f) : new URLSearchParams(); + const out: Record = {}; + for (const k of KEYS) out[k] = p.get(k); + return out; +} + /** Merge URL params onto a base filter set (typically the defaults). */ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters { const f: FeedFilters = { ...base }; @@ -117,20 +130,38 @@ export function readPage(): Page { return isPage(p) ? p : "feed"; } -/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the - * opt-in "Share view" action — filters are not otherwise written to the address bar. */ -export function shareUrl(f: FeedFilters, page: Page = "feed"): string { - const params = filtersToParams(f); - if (page !== "feed") params.set("page", page); +/** Build a URL (path + query) from the CURRENT location's query with `updates` applied — a null value + * deletes that key, any other string sets it. Lets each provider own its own nav key(s) — page / chan / + * v / pl / the feed-filter keys — and update the address bar without clobbering the others (read-modify- + * write on the live query). The caller decides pushState vs replaceState with the returned string. */ +export function urlWithParams(updates: Record): string { + const params = new URLSearchParams(window.location.search); + for (const [k, v] of Object.entries(updates)) { + if (v == null) params.delete(k); + else params.set(k, v); + } const qs = params.toString(); - return `${window.location.origin}${window.location.pathname}${qs ? `?${qs}` : ""}`; + return `${window.location.pathname}${qs ? `?${qs}` : ""}`; } -/** Strip the query string from the address bar (after hydrating from a share/auth link), - * leaving a clean URL. Preserves the current history.state (e.g. the in-app `sfPage` stamp) - * so it's safe to call at any time, not just before that stamp is written. */ -export function stripUrlParams(): void { - if (window.location.search) { - window.history.replaceState(window.history.state, "", window.location.pathname); +/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify, + * ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now + * live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now + * that the query is the source of truth for navigation. No-op when none of the keys are present. */ +export function clearParams(...keys: string[]): void { + const params = new URLSearchParams(window.location.search); + let changed = false; + for (const k of keys) { + if (params.has(k)) { + params.delete(k); + changed = true; + } } + if (!changed) return; + const qs = params.toString(); + window.history.replaceState( + window.history.state, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}`, + ); } From 4800df4208ad95f6898f1910c881bdbf81f2231c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 05:41:52 +0200 Subject: [PATCH 03/11] refactor(player): split PlayerModal god-component into hooks + controls (E4 S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Plex player split (plexPlayerHooks.ts / plexPlayerControls.tsx). The shell keeps owning the shared refs and passes them into each hook; the once-bound keyboard handler + layer-stack registration stay in the shell (they orchestrate every cluster). Behaviour-preserving code-motion — PlayerModal 1332 -> 890 lines. New lib/playerModalHooks.ts: - useYtVolume (per-account wheel/arrow volume, on shared playerVolume.ts) - useNativeControlsYield (iframe-focus overlay yield) - useYtBigView (F-maximise + our true-fullscreen; YT-specific, not Plex's useFullscreen) - useYtProgress (player create + resume + 5s checkpoint + auto-watch + auto-advance) New components/playerModalControls.tsx: PlayerTransportControls (moved as-is) + the AutoMode/LoopMode types. Self-E2E (localdev): play a feed video -> plays; transport bar (Auto/Loop); wheel volume persists ({"volume":100}); F maximises + docked bar; exit big view; Close destroys the player. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest. Noted a pre-existing bug (kept byte-identical here, flagged separately): the native-yield effect removes a "blur-sm" listener but adds "blur" — the blur listener leaks. --- frontend/src/components/PlayerModal.tsx | 556 ++---------------- .../src/components/playerModalControls.tsx | 115 ++++ frontend/src/lib/playerModalHooks.ts | 463 +++++++++++++++ 3 files changed, 635 insertions(+), 499 deletions(-) create mode 100644 frontend/src/components/playerModalControls.tsx create mode 100644 frontend/src/lib/playerModalHooks.ts diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 25fe79bc..77adbcab 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -5,6 +5,13 @@ import { useNavigationActions } from "./NavigationProvider"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { usePref } from "../lib/usePref"; +import { + AUTO_MODES, + LOOP_MODES, + PlayerTransportControls, + type AutoMode, + type LoopMode, +} from "./playerModalControls"; import { AlertTriangle, ArrowLeft, @@ -16,12 +23,7 @@ import { Maximize, Minimize, Minimize2, - Repeat, - Repeat1, Settings, - Shuffle, - SkipBack, - SkipForward, Volume2, VolumeX, X, @@ -32,7 +34,7 @@ import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; -import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube"; +import type { YTPlayer } from "../lib/youtube"; import { channelYouTubeUrl, formatDate, @@ -41,15 +43,14 @@ import { relativeTime, } from "../lib/format"; import { renderDescription } from "../lib/descriptionLinks"; +import { fullscreenElement } from "../lib/fullscreen"; +import { useBackToClose } from "../lib/history"; import { - exitFullscreen, - fullscreenElement, - onFullscreenChange, - requestFullscreen, -} from "../lib/fullscreen"; -import { useBackToClose, useBackToExit } from "../lib/history"; -import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "../lib/playerVolume"; -import { LS, readAccountMerged, writeAccount } from "../lib/storage"; + useNativeControlsYield, + useYtBigView, + useYtProgress, + useYtVolume, +} from "../lib/playerModalHooks"; import { useScrollFade } from "../lib/useScrollFade"; // Experiment (branch experiment/inline-player): play the video in-app via the @@ -58,119 +59,6 @@ import { useScrollFade } from "../lib/useScrollFade"; // button, the backdrop, or ESC (ESC only while focus is on our page, not inside // the cross-origin player iframe — a browser security boundary we can't cross). -// How close to the end (seconds) counts as "finished" → auto-mark watched. -const FINISH_MARGIN = 10; - -// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a -// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends -// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist). -type AutoMode = "off" | "next" | "prev" | "random"; -type LoopMode = "off" | "one" | "all"; -const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; -const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; - -/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. - * Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass - * theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark, - * labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one, - * so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */ -function PlayerTransportControls({ - variant, - index, - queueLength, - autoMode, - loopMode, - onPrev, - onNext, - onCycleAuto, - onCycleLoop, -}: { - variant: "docked" | "modal"; - index: number; - queueLength: number; - autoMode: AutoMode; - loopMode: LoopMode; - onPrev: () => void; - onNext: () => void; - onCycleAuto: () => void; - onCycleLoop: () => void; -}) { - const { t } = useTranslation(); - const docked = variant === "docked"; - const navBtn = docked - ? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition" - : "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"; - const navLabel = docked ? "hidden sm:inline" : ""; - const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; - const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; - const toggle = (on: boolean) => - docked - ? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${ - on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white" - }` - : `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${ - on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg" - }`; - const toggleLabel = docked ? "hidden md:inline" : ""; - return ( - <> - - - {index + 1} / {queueLength} - - - - {/* Persistent playback settings — cycle on click, saved to your account. */} - - - - ); -} - /** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)? * Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut * must not eat the only keyboard way to read a long description. Stops at ``: the page @@ -186,26 +74,6 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { return false; } -// --- IFrame Player API loader (singleton) --- -let apiPromise: Promise | null = null; -function loadYouTubeApi(): Promise { - if (apiPromise) return apiPromise; - apiPromise = new Promise((resolve) => { - const w = window; - if (w.YT && w.YT.Player) return resolve(w.YT); - const prev = w.onYouTubeIframeAPIReady; - w.onYouTubeIframeAPIReady = () => { - if (typeof prev === "function") prev(); - // The API-ready callback only fires once the script has installed window.YT. - resolve(w.YT!); - }; - const tag = document.createElement("script"); - tag.src = "https://www.youtube.com/iframe_api"; - document.head.appendChild(tag); - }); - return apiPromise; -} - export default function PlayerModal({ video, startAt, @@ -265,11 +133,6 @@ export default function PlayerModal({ startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); const playerRef = useRef(null); - const autoMarkedRef = useRef(false); - // Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a - // run of dead videos — after the cap we stop on the error overlay instead of skipping forever. - const MAX_CONSECUTIVE_ERRORS = 3; - const errorStreakRef = useRef(0); // Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player // iframe) so F / Space / Esc keep working until the user clicks into YouTube's native // controls; the interaction overlay below also stops the iframe stealing focus on a video @@ -279,52 +142,6 @@ export default function PlayerModal({ // The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the // modal adjusts volume (not just the small player area). const dialogRef = useRef(null); - const volTimerRef = useRef(undefined); - // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment. - const [volumeUi, setVolumeUi] = useState(null); - // When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the - // cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events - // so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully - // navigable (previously the overlay ate clicks below the top items). Moving the pointer off the - // video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts. - const [nativeControls, setNativeControls] = useState(false); - // True while OUR stage element is the fullscreen element. In fullscreen the interaction overlay - // yields entirely (see the overlay's pointer-events below): YouTube's control bar only appears - // while the iframe itself sees mouse movement, so an armed overlay kept the bar — and with it - // the native fullscreen button — permanently hidden after pressing F. - const [isFullscreen, setIsFullscreen] = useState(false); - // Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen. - const [maximized, setMaximized] = useState(false); - const maximizedRef = useRef(false); - maximizedRef.current = maximized; - // Mirrored into a ref because the keydown handler is bound once (deps: [onClose]) and would - // otherwise read `false` forever — the house pattern here (see navRef/volumeRef). - const isFullscreenRef = useRef(false); - // Nested Back: peel fullscreen → maximise → close, one browser/mouse Back at a time (each is a - // history layer only while active). Mirrors the Esc ladder in the keydown handler below. - useBackToExit(isFullscreen, exitFullscreen); - useBackToExit(maximized, () => setMaximized(false)); - // Volume survives the modal, the next video and a reload: a YT.Player is created per video and - // always starts at full volume. Per-account, like the Plex player's prefs. Level 0 IS the muted - // state (that's how YouTube's own control behaves), so there's no separate `muted` flag to - // contradict it. - // - // Ref + explicit read/write rather than `useAccountPersistedObject`: nothing RENDERS the level - // (the on-player flash has its own `volumeUi` state), the once-bound wheel/key listeners need a - // ref anyway, and the hook's write lives inside a `setState` updater — which is both a second - // write on every settle and not something React must run while the component is unmounting. - // Sanitised HERE, at the boundary where the untrusted value enters (`normalizeVolume`, see - // lib/playerVolume): a stored `null` / `"80"` / missing key becomes the default once, instead of - // travelling into the arithmetic. Read via a lazy `useState` initializer so it happens once, not - // on every render (the house pattern — see the frozen queue above). - const [storedVolume] = useState(() => - normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume), - ); - const volumeRef = useRef(storedVolume); - const volSaveTimerRef = useRef(undefined); - // When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player). - const lastNudgeAtRef = useRef(0); - const focusModal = () => cardRef.current?.focus(); const togglePlay = () => { const p = playerRef.current; @@ -333,84 +150,28 @@ export default function PlayerModal({ p.pauseVideo?.(); // 1 === playing else p.playVideo?.(); }; - /** F = maximise, deliberately WITHOUT the Fullscreen API. - * - * The measured reason (Chrome/Brave/Firefox alike, so it is spec behaviour, not a browser bug): - * a cross-origin embed reads its fullscreen state from ITS OWN document. While WE hold the - * browser's fullscreen lock — on our wrapper or even on the iframe element — the YouTube player - * never learns it is fullscreen, so its own fullscreen button stays in the "enter" state and a - * click on it changes nothing visible. That is the reported bug: every other YouTube control - * (gear, CC, volume) works, only its fullscreen button looks dead. - * - * So we don't take the lock at all: `maximized` is a CSS layer that fills the viewport. The page - * stays non-fullscreen, which means the embed's own fullscreen button works exactly as it does - * anywhere else — and OUR shortcuts and overlays (wheel volume, the level flash, the queue bar) - * keep working, because our DOM is what's on screen. The one thing we give up is hiding the - * browser's own chrome; YouTube's button provides that, correctly, when the user wants it. - */ - const toggleFullscreen = () => setMaximized((m) => !m); - // True browser fullscreen that WE own (the stage element). Unlike YouTube's own fullscreen it keeps - // OUR overlay + control bar on screen, so wheel/arrow volume and our controls work inside it too. - // We never surface YouTube's own fullscreen button, so the "a cross-origin embed's own button looks - // dead while we hold the lock" problem that made F avoid the Fullscreen API does not arise. - const toggleTrueFullscreen = () => { - if (fullscreenElement()) exitFullscreen(); - else if (stageRef.current) requestFullscreen(stageRef.current); - }; - // Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise. - const exitBigView = () => { - if (fullscreenElement()) exitFullscreen(); - setMaximized(false); - }; - const flashVolume = (level: number) => { - setVolumeUi(level); - window.clearTimeout(volTimerRef.current); - volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200); - }; - /** Write the current level out now, cancelling any pending debounce. */ - const persistVolume = () => { - window.clearTimeout(volSaveTimerRef.current); - volSaveTimerRef.current = undefined; - writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current }); - }; - /** Push a level (0–100) to the player and remember it; 0 also mutes (a player left at zero - * volume but unmuted shows YouTube's speaker icon as ON, which reads as broken). */ - const applyVolume = (level: number, flash = true) => { - // Callers hand over an already-normalised level (stepVolume / the stored value), but the - // player can still answer NaN, so re-normalise here against the LAST GOOD level — never the - // default: jumping a quiet session to full blast is the surprise this feature exists to avoid. - const next = normalizeVolume(level, volumeRef.current); - const p = playerRef.current; - if (p && typeof p.setVolume === "function") { - if (next > 0) p.unMute?.(); - p.setVolume(next); - if (next === 0) p.mute?.(); - } - volumeRef.current = next; - // Debounced: one write when the spin settles, not one per notch. The unmount cleanup flushes - // a pending write, so the last change is never lost. - window.clearTimeout(volSaveTimerRef.current); - volSaveTimerRef.current = window.setTimeout(persistVolume, 400); - if (flash) flashVolume(next); - }; - const nudgeVolume = (delta: number) => { - // No early return when the player isn't ready yet: the level lives in `volumeRef` and onReady - // applies it, so a wheel spin in the first second after opening (the natural reaction to a - // video that starts loud) now lands instead of being silently dropped. - const p = playerRef.current; - // Whose level do we step from? Our own ref, EXCEPT when the gesture starts fresh — then read - // the player, because the user may have moved YouTube's own slider in the meantime. - // `getVolume()` is answered across the iframe boundary and lags our `setVolume` by a beat, so - // re-reading it mid-spin resurrects a stale level and silently drops notches (measured: five - // wheel notches moved 100 → 90 instead of 75). - const now = Date.now(); - const continuing = now - lastNudgeAtRef.current < 1000; - lastNudgeAtRef.current = now; - const live = - p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null; - const cur = continuing || live === null ? volumeRef.current : live; - applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current })); - }; + // Per-account volume (wheel over the whole modal + ↑/↓): the level lives in the hook's ref and is + // re-applied to each freshly-created player via reapplyStored (onReady). + const { volumeUi, reapplyStored, nudgeVolume } = useYtVolume({ + playerRef, + dialogRef, + focusModal, + }); + const { nativeControls, setNativeControls } = useNativeControlsYield({ + playerRef, + stageRef, + focusModal, + }); + const { + isFullscreen, + maximized, + maximizedRef, + isFullscreenRef, + toggleFullscreen, + toggleTrueFullscreen, + exitBigView, + setMaximized, + } = useYtBigView({ stageRef, playerRef, focusModal }); // The player can navigate to other videos (YouTube links in a description). The // currently-playing id may differ from the active item. @@ -639,232 +400,29 @@ export default function PlayerModal({ return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; - window.clearTimeout(volTimerRef.current); - // Flush a debounced volume write — closing the player right after a wheel spin (the normal - // way to leave) must not drop the level the user just set. - if (volSaveTimerRef.current != null) persistVolume(); + // (The debounced volume write is flushed by useYtVolume's own unmount cleanup.) }; }, [onClose]); - // Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area. - // Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe - // swallows wheel events over its own native-control strips, but the central interaction overlay - // is our element (wheel there bubbles here), and everything outside the video works directly. - useEffect(() => { - const el = dialogRef.current; - if (!el) return; - const onWheel = (e: WheelEvent) => { - // Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. Volume is the PLAIN wheel - // (and ↑/↓); taking the modifier too left zoom unreachable while the player was open. - if (e.ctrlKey || e.metaKey) return; - e.preventDefault(); - focusModal(); // re-grab focus if it had slipped into the native controls - nudgeVolume(e.deltaY < 0 ? 5 : -5); - }; - el.addEventListener("wheel", onWheel, { passive: false }); - return () => el.removeEventListener("wheel", onWheel); - }, []); - - // Track the browser's fullscreen state. WE never request it any more (F maximises via CSS), so - // this is YouTube's own fullscreen — entered from its control bar, left by its button or Esc. - useEffect(() => { - const onChange = () => { - const fsEl = fullscreenElement(); - // Refs only — this listener is bound once, so it must not close over render values. - const p = playerRef.current; - const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null; - const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame); - isFullscreenRef.current = ours; // read by the once-bound keydown handler - setIsFullscreen(ours); // drives the overlay's pointer-events - // LEAVING fullscreen is where the user got stranded: to reach YouTube's fullscreen button - // they had to click INTO the player, which moves keyboard focus into the cross-origin iframe - // — and it stays there afterwards. Our window-level shortcuts then never fire (F went to - // YouTube instead, giving a second, control-less fullscreen), so the maximised view had no - // way out. Taking focus back here restores F/Space/arrows the moment fullscreen ends. - if (!fsEl) focusModal(); - }; - return onFullscreenChange(onChange); // both spellings — see lib/fullscreen - }, []); - - // Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek - // bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the - // iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings - // menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard - // shortcuts — when the pointer leaves the video or the window regains focus (a click back on our - // card blurs the iframe → the parent window fires 'focus'). - useEffect(() => { - const iframeFocused = () => { - const p = playerRef.current; - const f = p && typeof p.getIframe === "function" ? p.getIframe() : null; - return !!f && document.activeElement === f; - }; - // activeElement settles on the tick after blur, so defer the check. - const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0); - const rearm = () => { - setNativeControls(false); - focusModal(); - }; - const stage = stageRef.current; - window.addEventListener("blur", onBlur); - window.addEventListener("focus", rearm); - stage?.addEventListener("mouseleave", rearm); - return () => { - window.removeEventListener("blur-sm", onBlur); - window.removeEventListener("focus", rearm); - stage?.removeEventListener("mouseleave", rearm); - }; - }, []); - - // Create the player, resume from the saved position, persist progress, and - // auto-mark watched once playback reaches the end. - useEffect(() => { - let cancelled = false; - const id = active.id; - autoMarkedRef.current = false; - setPlayerError(null); - - // Auto-watch only applies to the active item — not to other videos the player - // navigated to via description links. - const maybeAutoWatch = (current: number, duration: number): boolean => { - if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false; - // Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't - // auto-marked watched almost immediately (its threshold would otherwise go negative). - const margin = Math.min(FINISH_MARGIN, duration / 2); - if (current > duration - margin) { - autoMarkedRef.current = true; - setWatched(true); - return true; - } - return false; - }; - const persist = (): Promise | void => { - const p = playerRef.current; - if (!p || typeof p.getCurrentTime !== "function") return; - try { - const cur = p.getCurrentTime(); - const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; - // If we just auto-marked watched, skip the near-end progress checkpoint: it would - // only clear the resume position, and firing both at once needlessly races the - // watched write against the progress row it deletes. - if (maybeAutoWatch(cur, dur)) return; - // Only checkpoint the feed video we opened with — a navigated-to video may not - // be in this user's library, so the server would 404 on it. - if (currentIdRef.current === id) { - return api.saveProgress(id, cur, dur).catch(() => {}); - } - } catch { - /* player may be tearing down */ - } - }; - - loadYouTubeApi().then((YT) => { - if (cancelled || !mountRef.current) return; - playerRef.current = new YT.Player(mountRef.current, { - width: "100%", - height: "100%", - videoId: id, - playerVars: { - autoplay: 1, - start: resumeAt || undefined, - rel: 0, // limit "related" to the same channel (full removal is no longer possible) - enablejsapi: 1, - origin: window.location.origin, - playsinline: 1, - // Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops - // now (YouTube removed them), and the undocumented `vq` URL param is only occasionally - // honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by - // the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen - // lifts the cap (after which the higher pick persists for the session). We can't beat that - // programmatically — a CSS transform-scale trick was tried and does NOT fool the cap. - vq: "hd1080", - }, - events: { - // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and - // restore the remembered volume — a fresh player always starts at 100. - onReady: () => { - focusModal(); - applyVolume(volumeRef.current, false); - }, - onStateChange: (e: YTPlayerEvent) => { - // 1 === playing → sync the navigated-to video's title/author for display. - if (e?.data === 1) { - errorStreakRef.current = 0; // a successful play breaks any unplayable run - const p = playerRef.current; - const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; - if (d) setLiveData({ title: d.title, author: d.author }); - } - // 0 === ended → mark watched, then advance to the next queue item if any. - if (e?.data === 0 && currentIdRef.current === id) { - if (!autoMarkedRef.current) { - autoMarkedRef.current = true; - setWatched(true); - } - advanceOnEnd(); - } - }, - // The embed couldn't play this video (embedding disabled, removed, private…). - // Surface our own message + an "Open on YouTube" escape hatch. - onError: (e: YTPlayerEvent) => { - setPlayerError(typeof e?.data === "number" ? e.data : -1); - // The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd() - // never runs and an auto-advancing queue would freeze here. Skip forward instead — but - // bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do - // NOT mark the broken video watched (only the ended-handler does that). - if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone - const { autoMode: a } = modeRef.current; - const { queue: q } = navRef.current; - if (a === "off" || !q || q.length <= 1) return; - errorStreakRef.current += 1; - if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay - advanceOnEnd(); - }, - }, - }); - }); - - // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh. - const timer = window.setInterval(persist, 5000); - // Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so - // without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive - // beacon so the POST survives the unload. Only for the library item we opened with — a navigated - // description-link video may not be in this user's library (the server would 404). - const onHide = () => { - const p = playerRef.current; - if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return; - try { - const cur = p.getCurrentTime(); - const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; - if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur); - } catch { - /* player tearing down */ - } - }; - window.addEventListener("pagehide", onHide); - - return () => { - cancelled = true; - window.clearInterval(timer); - window.removeEventListener("pagehide", onHide); - // Final flush, then mark the feed + playlists stale so resume bars reflect this session on the - // next natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2: - // opening/closing the player through a watch session otherwise re-runs the whole infinite query - // each time). Progress is already persisted server-side; only the on-screen resume bar waits. - Promise.resolve(persist()).finally(() => { - qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); - qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" }); - }); - const p = playerRef.current; - if (p && typeof p.destroy === "function") { - try { - p.destroy(); - } catch { - /* ignore */ - } - } - playerRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [active.id]); + // Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at + // the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via + // the refs passed in. See lib/playerModalHooks. + useYtProgress({ + activeId: active.id, + resumeAt, + mountRef, + playerRef, + currentIdRef, + navRef, + modeRef, + setWatched, + setPlayerError, + setLiveData, + reapplyStored, + focusModal, + advanceOnEnd, + qc, + }); // Portaled to , like every other full-screen overlay here. A fixed, full-viewport modal // must not render inside the page scroller: once that scroller is a stacking context (its diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/components/playerModalControls.tsx new file mode 100644 index 00000000..ddc89092 --- /dev/null +++ b/frontend/src/components/playerModalControls.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from "react-i18next"; +import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; + +// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component — +// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props. + +// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a video +// ends; loop = whether it repeats the current video ("one"), wraps the list at its ends ("all"), or +// neither ("off"). Both apply to any queued player (feed or playlist). +export type AutoMode = "off" | "next" | "prev" | "random"; +export type LoopMode = "off" | "one" | "all"; +export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; +export const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; + +/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. + * Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass + * theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark, + * labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one, + * so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */ +export function PlayerTransportControls({ + variant, + index, + queueLength, + autoMode, + loopMode, + onPrev, + onNext, + onCycleAuto, + onCycleLoop, +}: { + variant: "docked" | "modal"; + index: number; + queueLength: number; + autoMode: AutoMode; + loopMode: LoopMode; + onPrev: () => void; + onNext: () => void; + onCycleAuto: () => void; + onCycleLoop: () => void; +}) { + const { t } = useTranslation(); + const docked = variant === "docked"; + const navBtn = docked + ? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition" + : "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"; + const navLabel = docked ? "hidden sm:inline" : ""; + const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; + const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; + const toggle = (on: boolean) => + docked + ? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${ + on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white" + }` + : `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${ + on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg" + }`; + const toggleLabel = docked ? "hidden md:inline" : ""; + return ( + <> + + + {index + 1} / {queueLength} + + + + {/* Persistent playback settings — cycle on click, saved to your account. */} + + + + ); +} diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts new file mode 100644 index 00000000..9bdc59b1 --- /dev/null +++ b/frontend/src/lib/playerModalHooks.ts @@ -0,0 +1,463 @@ +import { + useEffect, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type RefObject, + type SetStateAction, +} from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import { DEFAULT_VOLUME, normalizeVolume, stepVolume } from "./playerVolume"; +import { LS, readAccountMerged, writeAccount } from "./storage"; +import { + exitFullscreen, + fullscreenElement, + onFullscreenChange, + requestFullscreen, +} from "./fullscreen"; +import { useBackToExit } from "./history"; +import { api, type Video } from "./api"; +import { qk } from "./queryKeys"; +import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube"; +import type { AutoMode, LoopMode } from "../components/playerModalControls"; + +// How close to the end (seconds) counts as "finished" → auto-mark watched. +const FINISH_MARGIN = 10; +// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a run +// of dead videos — after the cap we stop on the error overlay instead of skipping forever. +const MAX_CONSECUTIVE_ERRORS = 3; + +// --- IFrame Player API loader (singleton) --- +let apiPromise: Promise | null = null; +function loadYouTubeApi(): Promise { + if (apiPromise) return apiPromise; + apiPromise = new Promise((resolve) => { + const w = window; + if (w.YT && w.YT.Player) return resolve(w.YT); + const prev = w.onYouTubeIframeAPIReady; + w.onYouTubeIframeAPIReady = () => { + if (typeof prev === "function") prev(); + // The API-ready callback only fires once the script has installed window.YT. + resolve(w.YT!); + }; + const tag = document.createElement("script"); + tag.src = "https://www.youtube.com/iframe_api"; + document.head.appendChild(tag); + }); + return apiPromise; +} + +// Extracted hooks for the YouTube modal player (PlayerModal), the sibling of plexPlayerHooks.ts. The +// component owns the shared media refs (playerRef, stageRef, dialogRef) and passes them in via an opts +// object; each hook owns its own private state/timers and returns handlers + reactive values. The +// once-bound keyboard handler and layer-stack registration stay in the component shell (they orchestrate +// every hook through refs), exactly as PlexPlayer keeps its keydown handler while consuming these hooks. + +/** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created + * per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS + * the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag. + * Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */ +export function useYtVolume({ + playerRef, + dialogRef, + focusModal, +}: { + playerRef: MutableRefObject; + dialogRef: RefObject; + focusModal: () => void; +}): { + /** Level (0–100) to flash in the on-player overlay, or null when hidden. */ + volumeUi: number | null; + /** Re-apply the remembered level to a freshly-created player (onReady), without flashing. */ + reapplyStored: () => void; + /** Step the level by `delta` from the freshest good source (own ref, or the player on a fresh gesture). */ + nudgeVolume: (delta: number) => void; +} { + const volTimerRef = useRef(undefined); + // Sanitised HERE, at the boundary where the untrusted stored value enters (normalizeVolume): a `null` + // / `"80"` / missing key becomes the default ONCE, via a lazy useState so it happens once per mount. + const [volumeUi, setVolumeUi] = useState(null); + const [storedVolume] = useState(() => + normalizeVolume(readAccountMerged(LS.ytPlayerPrefs, { volume: DEFAULT_VOLUME }).volume), + ); + const volumeRef = useRef(storedVolume); + const volSaveTimerRef = useRef(undefined); + // When the last volume gesture happened — see nudgeVolume (a spin must not re-read the player). + const lastNudgeAtRef = useRef(0); + + const flashVolume = (level: number) => { + setVolumeUi(level); + window.clearTimeout(volTimerRef.current); + volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200); + }; + const persistVolume = () => { + window.clearTimeout(volSaveTimerRef.current); + volSaveTimerRef.current = undefined; + writeAccount(LS.ytPlayerPrefs, { volume: volumeRef.current }); + }; + const applyVolume = (level: number, flash = true) => { + // Re-normalise against the LAST GOOD level (never the default) — the player can answer NaN, and + // jumping a quiet session to full blast is the surprise this feature exists to avoid. + const next = normalizeVolume(level, volumeRef.current); + const p = playerRef.current; + if (p && typeof p.setVolume === "function") { + if (next > 0) p.unMute?.(); + p.setVolume(next); + if (next === 0) p.mute?.(); + } + volumeRef.current = next; + // Debounced: one write when the spin settles; the unmount cleanup flushes a pending write. + window.clearTimeout(volSaveTimerRef.current); + volSaveTimerRef.current = window.setTimeout(persistVolume, 400); + if (flash) flashVolume(next); + }; + const nudgeVolume = (delta: number) => { + // No early return when the player isn't ready: the level lives in volumeRef and onReady applies it, + // so a wheel spin in the first second after opening still lands. + const p = playerRef.current; + // Step from our own ref, EXCEPT when the gesture starts fresh — then read the player (the user may + // have moved YouTube's own slider). getVolume() lags setVolume across the iframe boundary, so + // re-reading it mid-spin resurrects a stale level and silently drops notches. + const now = Date.now(); + const continuing = now - lastNudgeAtRef.current < 1000; + lastNudgeAtRef.current = now; + const live = + p && typeof p.getVolume === "function" ? (p.isMuted?.() ? 0 : p.getVolume()) : null; + const cur = continuing || live === null ? volumeRef.current : live; + applyVolume(stepVolume(cur, { delta, fallback: volumeRef.current })); + }; + + // Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page + // scrolling. The cross-origin iframe swallows wheel over its native strips, but the central + // interaction overlay is our element (wheel there bubbles here), and everything outside works. + useEffect(() => { + const el = dialogRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + // Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. + if (e.ctrlKey || e.metaKey) return; + e.preventDefault(); + focusModal(); // re-grab focus if it had slipped into the native controls + nudgeVolume(e.deltaY < 0 ? 5 : -5); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // On unmount, clear the flash timer and flush any pending debounced write — closing the player right + // after a wheel spin (the normal way to leave) must not drop the level the user just set. + useEffect( + () => () => { + window.clearTimeout(volTimerRef.current); + if (volSaveTimerRef.current != null) persistVolume(); + }, + [], + ); + + return { + volumeUi, + reapplyStored: () => applyVolume(volumeRef.current, false), + nudgeVolume, + }; +} + +/** Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek bar / + * CC) moves focus into the cross-origin player iframe — the only signal we get. While the iframe holds + * focus we drop the overlay's pointer-events so the (arbitrarily tall) settings menu is clickable end to + * end. Re-arm — restoring click-to-play, wheel volume and our keyboard shortcuts — when the pointer + * leaves the video or the window regains focus. */ +export function useNativeControlsYield({ + playerRef, + stageRef, + focusModal, +}: { + playerRef: MutableRefObject; + stageRef: RefObject; + focusModal: () => void; +}): { + nativeControls: boolean; + setNativeControls: (on: boolean) => void; +} { + const [nativeControls, setNativeControls] = useState(false); + useEffect(() => { + const iframeFocused = () => { + const p = playerRef.current; + const f = p && typeof p.getIframe === "function" ? p.getIframe() : null; + return !!f && document.activeElement === f; + }; + // activeElement settles on the tick after blur, so defer the check. + const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0); + const rearm = () => { + setNativeControls(false); + focusModal(); + }; + const stage = stageRef.current; + window.addEventListener("blur", onBlur); + window.addEventListener("focus", rearm); + stage?.addEventListener("mouseleave", rearm); + return () => { + window.removeEventListener("blur-sm", onBlur); + window.removeEventListener("focus", rearm); + stage?.removeEventListener("mouseleave", rearm); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return { nativeControls, setNativeControls }; +} + +/** The YouTube player's big views: `maximized` (the F key — a CSS layer that fills the viewport, + * deliberately NOT the Fullscreen API, so the cross-origin embed's OWN fullscreen button keeps working) + * and true browser fullscreen that WE own on the stage element (keeps our overlay + control bar on + * screen). Both back out one step at a time via the history layers (fullscreen → maximise → close). This + * is deliberately YT-specific and does NOT reuse Plex's useFullscreen. The refs are the bridge the + * once-bound keyboard handler reads. */ +export function useYtBigView({ + stageRef, + playerRef, + focusModal, +}: { + stageRef: RefObject; + playerRef: MutableRefObject; + focusModal: () => void; +}): { + isFullscreen: boolean; + maximized: boolean; + maximizedRef: MutableRefObject; + isFullscreenRef: MutableRefObject; + toggleFullscreen: () => void; + toggleTrueFullscreen: () => void; + exitBigView: () => void; + setMaximized: Dispatch>; +} { + // True while OUR stage is the fullscreen element (drives the overlay's pointer-events). + const [isFullscreen, setIsFullscreen] = useState(false); + // Our own "fills the viewport" mode (the F key). Not the Fullscreen API — see toggleFullscreen. + const [maximized, setMaximized] = useState(false); + const maximizedRef = useRef(false); + maximizedRef.current = maximized; + // Mirrored into a ref because the keydown handler is bound once and would otherwise read false forever. + const isFullscreenRef = useRef(false); + // Nested Back: peel fullscreen → maximise → close, one Back at a time (each a history layer only while + // active). Mirrors the Esc ladder in the component's keydown handler. + useBackToExit(isFullscreen, exitFullscreen); + useBackToExit(maximized, () => setMaximized(false)); + + const toggleFullscreen = () => setMaximized((m) => !m); + // True browser fullscreen WE own (the stage element) — keeps our overlay + controls on screen. We + // never surface YouTube's own fullscreen button, so the "cross-origin button looks dead while we hold + // the lock" problem that made F avoid the Fullscreen API doesn't arise here. + const toggleTrueFullscreen = () => { + if (fullscreenElement()) exitFullscreen(); + else if (stageRef.current) requestFullscreen(stageRef.current); + }; + // Leave the big view back to the modal card: drop fullscreen first if held, then un-maximise. + const exitBigView = () => { + if (fullscreenElement()) exitFullscreen(); + setMaximized(false); + }; + + // Track the browser's fullscreen state. WE never request it via F (that maximises via CSS), so this is + // YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING, + // focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our + // window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends. + useEffect(() => { + const onChange = () => { + const fsEl = fullscreenElement(); + // Refs only — this listener is bound once, so it must not close over render values. + const p = playerRef.current; + const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null; + const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame); + isFullscreenRef.current = ours; // read by the once-bound keydown handler + setIsFullscreen(ours); // drives the overlay's pointer-events + if (!fsEl) focusModal(); + }; + return onFullscreenChange(onChange); // both spellings — see lib/fullscreen + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + isFullscreen, + maximized, + maximizedRef, + isFullscreenRef, + toggleFullscreen, + toggleTrueFullscreen, + exitBigView, + setMaximized, + }; +} + +/** Create the YouTube IFrame player for the active item, resume from the saved position, checkpoint + * progress every 5 s (+ a pagehide beacon), and auto-mark watched once playback reaches the end. Keyed + * on activeId (a queue advance recreates the player to reuse this single-video logic). The once-bound + * event closures read fresh queue/mode/volume via the refs passed in. Mirrors Plex's usePlexProgress — + * side-effect only, returns nothing. */ +export function useYtProgress({ + activeId, + resumeAt, + mountRef, + playerRef, + currentIdRef, + navRef, + modeRef, + setWatched, + setPlayerError, + setLiveData, + reapplyStored, + focusModal, + advanceOnEnd, + qc, +}: { + activeId: string; + resumeAt: number; + mountRef: RefObject; + playerRef: MutableRefObject; + currentIdRef: MutableRefObject; + navRef: MutableRefObject<{ queue: Video[] | undefined; index: number }>; + modeRef: MutableRefObject<{ autoMode: AutoMode; loopMode: LoopMode }>; + setWatched: (on: boolean) => void; + setPlayerError: (code: number | null) => void; + setLiveData: (d: { title?: string; author?: string } | null) => void; + reapplyStored: () => void; + focusModal: () => void; + advanceOnEnd: () => void; + qc: QueryClient; +}): void { + const autoMarkedRef = useRef(false); + const errorStreakRef = useRef(0); + + useEffect(() => { + let cancelled = false; + const id = activeId; + autoMarkedRef.current = false; + setPlayerError(null); + + // Auto-watch only applies to the active item — not to other videos the player navigated to via + // description links. Cap the finish margin at half the clip so a very short video isn't auto-marked + // almost immediately. + const maybeAutoWatch = (current: number, duration: number): boolean => { + if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false; + const margin = Math.min(FINISH_MARGIN, duration / 2); + if (current > duration - margin) { + autoMarkedRef.current = true; + setWatched(true); + return true; + } + return false; + }; + const persist = (): Promise | void => { + const p = playerRef.current; + if (!p || typeof p.getCurrentTime !== "function") return; + try { + const cur = p.getCurrentTime(); + const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; + // If we just auto-marked watched, skip the near-end checkpoint (it would only clear the resume + // position, racing the watched write against the progress row it deletes). + if (maybeAutoWatch(cur, dur)) return; + // Only checkpoint the library item we opened with — a navigated-to video may not be in this + // user's library, so the server would 404 on it. + if (currentIdRef.current === id) { + return api.saveProgress(id, cur, dur).catch(() => {}); + } + } catch { + /* player may be tearing down */ + } + }; + + loadYouTubeApi().then((YT) => { + if (cancelled || !mountRef.current) return; + playerRef.current = new YT.Player(mountRef.current, { + width: "100%", + height: "100%", + videoId: id, + playerVars: { + autoplay: 1, + start: resumeAt || undefined, + rel: 0, // limit "related" to the same channel (full removal is no longer possible) + enablejsapi: 1, + origin: window.location.origin, + playsinline: 1, + // Best-effort HD hint (mostly a no-op now; YouTube hard-caps embed quality by on-screen size). + vq: "hd1080", + }, + events: { + onReady: () => { + focusModal(); + reapplyStored(); + }, + onStateChange: (e: YTPlayerEvent) => { + // 1 === playing → sync the navigated-to video's title/author for display. + if (e?.data === 1) { + errorStreakRef.current = 0; // a successful play breaks any unplayable run + const p = playerRef.current; + const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; + if (d) setLiveData({ title: d.title, author: d.author }); + } + // 0 === ended → mark watched, then advance to the next queue item if any. + if (e?.data === 0 && currentIdRef.current === id) { + if (!autoMarkedRef.current) { + autoMarkedRef.current = true; + setWatched(true); + } + advanceOnEnd(); + } + }, + onError: (e: YTPlayerEvent) => { + setPlayerError(typeof e?.data === "number" ? e.data : -1); + // onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an + // auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead + // items can't spin. We do NOT mark the broken video watched. + if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone + const { autoMode: a } = modeRef.current; + const { queue: q } = navRef.current; + if (a === "off" || !q || q.length <= 1) return; + errorStreakRef.current += 1; + if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay + advanceOnEnd(); + }, + }, + }); + }); + + // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh. + const timer = window.setInterval(persist, 5000); + // Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload. A + // keepalive beacon so the POST survives the unload. Only for the library item we opened with. + const onHide = () => { + const p = playerRef.current; + if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return; + try { + const cur = p.getCurrentTime(); + const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; + if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur); + } catch { + /* player tearing down */ + } + }; + window.addEventListener("pagehide", onHide); + + return () => { + cancelled = true; + window.clearInterval(timer); + window.removeEventListener("pagehide", onHide); + // Final flush, then mark the feed + playlists stale so resume bars reflect this session on the next + // natural refetch — WITHOUT refetching every loaded feed page on each player close (PERF-2). + Promise.resolve(persist()).finally(() => { + qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); + qc.invalidateQueries({ queryKey: qk.playlist(), refetchType: "none" }); + }); + const p = playerRef.current; + if (p && typeof p.destroy === "function") { + try { + p.destroy(); + } catch { + /* ignore */ + } + } + playerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeId]); +} From 0b592345e65aad019bb342b8f652aae66a2fcb52 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 05:56:20 +0200 Subject: [PATCH 04/11] fix(player): remove the leaked window "blur" listener in useNativeControlsYield MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native-controls-yield effect registered addEventListener("blur", onBlur) but its cleanup removed "blur-sm" (a stray Tailwind-class find-replace artifact), so the blur listener was never removed — leaking one per player open/close. Match the names. --- frontend/src/lib/playerModalHooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index 9bdc59b1..2a64cca2 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -198,7 +198,7 @@ export function useNativeControlsYield({ window.addEventListener("focus", rearm); stage?.addEventListener("mouseleave", rearm); return () => { - window.removeEventListener("blur-sm", onBlur); + window.removeEventListener("blur", onBlur); window.removeEventListener("focus", rearm); stage?.removeEventListener("mouseleave", rearm); }; From 180ab56379b8fa66e87a4a3df331668c33aec4b9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 06:10:48 +0200 Subject: [PATCH 05/11] refactor(plex): split PlexBrowse god-file into cards + views + shared UI (E4 S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure behaviour-preserving code-motion — PlexBrowse 1751 -> 538 lines. The ~11 inlined components are self-contained (every view is invoked with shell callbacks as props; no view reaches back into shell state), so they just move to sibling files. New files: - lib/plexWatch.ts: toggleWatchState (shared by the grid + person page) - components/plexCards.tsx: PlexPosterCard, SeasonCard, CastStrip, RelatedStrip, EpisodeCard - components/plexViews.tsx: PlexInfoView, PlexPersonView, PlexShowView, PlexSeasonView - lib/plexDetailUi.tsx (extended): BackBtn, ActionBtn, WatchedToggle, DetailHero, epLabel (the file's own remit — "shared detail-page UI") PlexBrowse keeps the Sub router + PLEX_SUB_URL + PAGE + PlexBrowse() shell. Repointed the only external importer (PlexPersonView.dom.test) to plexCards/plexViews. Self-E2E (localdev): Plex grid renders (poster cards + episodes); The Expanse show-drill renders the hero + action buttons + season cards; ?v=show URL-mirroring intact; 0 console errors. Gate green: typecheck, knip, eslint (0 err), prettier, 247 vitest. --- frontend/src/components/PlexBrowse.tsx | 1240 +---------------- .../components/PlexPersonView.dom.test.tsx | 3 +- frontend/src/components/plexCards.tsx | 463 ++++++ frontend/src/components/plexViews.tsx | 615 ++++++++ frontend/src/lib/plexDetailUi.tsx | 137 +- frontend/src/lib/plexWatch.ts | 22 + 6 files changed, 1252 insertions(+), 1228 deletions(-) create mode 100644 frontend/src/components/plexCards.tsx create mode 100644 frontend/src/components/plexViews.tsx create mode 100644 frontend/src/lib/plexWatch.ts diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 4ec3fc5a..96e046c9 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,77 +1,39 @@ -import { - lazy, - Suspense, - useEffect, - useLayoutEffect, - useRef, - useState, - type CSSProperties, - type ReactNode, -} from "react"; -import { qk } from "../lib/queryKeys"; -import { healPlexField } from "../lib/plexSearch"; -import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; -import SortControl from "./SortControl"; +import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { plexLang } from "../i18n"; -import { pickRatingBadge } from "../lib/plexRating"; import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData, } from "@tanstack/react-query"; -import { - ArrowLeft, - Check, - CheckCheck, - CheckCircle2, - Film, - Info, - Layers, - ListPlus, - Play, - RotateCcw, - Star, - Tv2, - User, - X, - type LucideIcon, -} from "lucide-react"; +import { User, X } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { healPlexField } from "../lib/plexSearch"; +import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexCard, - type PlexCastMember, type PlexFilters, - type PlexPersonDetail, - type PlexSeasonDetail, type PlexUnifiedResult, } from "../lib/api"; -import { formatRuntime } from "../lib/format"; -import { notifyActionError } from "../lib/actionErrors"; +import { plexLang } from "../i18n"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview, type SubviewUrl } from "../lib/history"; -import { - DetailCustomizeMenu, - Filterable, - useArtBackdrop, - useDetailPrefs, -} from "../lib/plexDetailUi"; -import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; -import PlexCollectionEditor from "./PlexCollectionEditor"; -import { LoadingState, StateMessage } from "./QueryState"; +import { toggleWatchState } from "../lib/plexWatch"; +import SortControl from "./SortControl"; import { PageToolbar } from "./PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; +import { StateMessage } from "./QueryState"; import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters"; import { usePlex, usePlexActions } from "./PlexProvider"; +import { EpisodeCard, PlexPosterCard } from "./plexCards"; +import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); -const PlexInfo = lazy(() => import("./PlexInfo")); const PlexPlaylistView = lazy(() => import("./PlexPlaylistView")); type Sub = @@ -130,30 +92,11 @@ const PLEX_SUB_URL: SubviewUrl = { // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a // show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the // search term comes from the shared top Header search box (q). A show drill-down rides history -// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces -// for now. Rendered by App for page==="plex" (its own nav module). +// (useHistorySubview) so browser Back returns to the grid. Rendered by App for page==="plex" (its own +// nav module). The cards/strips live in plexCards; the sub-view branches in plexViews. const PAGE = 40; -// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache -// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then -// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the -// browse grid and the person page, each of which owns a different cache shape. -async function toggleWatchState( - card: PlexCard, - t: TFunction, - handlers: { optimistic: (next: string) => void; resync: () => void }, -) { - const next = card.status === "watched" ? "new" : "watched"; - handlers.optimistic(next); - try { - await api.plexSetState(card.id, next); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } - handlers.resync(); -} - export default function PlexBrowse() { const { t } = useTranslation(); const qc = useQueryClient(); @@ -454,7 +397,7 @@ export default function PlexBrowse() {

{browseQ.isLoading - ? " " + ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })} @@ -593,1158 +536,3 @@ export default function PlexBrowse() {

); } - -// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage -// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient; -// all three stay visually distinct. -const PLAYABLE_TINT: Record = { - direct: "bg-success", - remux: "bg-warn", - transcode: "bg-danger", -}; - -function PlexPosterCard({ - card, - onOpen, - onInfo, - onToggleWatched, -}: { - card: PlexCard; - onOpen: () => void; - onInfo: () => void; - onToggleWatched: (c: PlexCard) => void; -}) { - const { t } = useTranslation(); - const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; - const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down - const pct = - inProgress && card.duration_seconds - ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) - : 0; - return ( -
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" - > - - {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */} -
-
- - - {card.type === "show" - ? t("plex.openShow") - : inProgress - ? t("plex.resume") - : t("plex.play")} - -
-
- {/* Quick watched toggle (movies/episodes), on hover. */} - {isPlayable && ( - - )} - {/* Media info page (movies/episodes). */} - {isPlayable && ( - - )} - {/* Watched badge when not hovering (the toggle replaces it on hover). */} - {card.status === "watched" && ( - - {t("plex.watched")} - - )} - {card.type === "show" && card.season_count != null && ( - - {t("plex.seasons", { count: card.season_count })} - - )} - {/* Aggregate in-progress badge for a partially-watched show. */} - {card.type === "show" && card.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - {card.playable && ( - - )} - {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} - - {card.type === "show" ? : } - - {pct > 0 && ( -
-
-
- )} -
-
-
- {card.title} -
-
- {[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")} -
-
-
- ); -} - -function PlexInfoView({ - id, - onBack, - onPlay, - onPlayItem, - onFilter, - onOpenPerson, -}: { - id: string; - onBack: () => void; - onPlay: () => void; - onPlayItem: (id: string) => void; - onFilter: (patch: Partial) => void; - onOpenPerson: (name: string) => void; -}) { - const { t } = useTranslation(); - const lang = plexLang(); - const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); - return ( -
-
- -
- {q.isError && !q.data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !q.data ? ( - - ) : ( - {t("plex.loading")}

}> - -
- )} -
- ); -} - -// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) --------------- -// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it -// shows the person's headshot + name, a "filter the library by them" action, and their titles as two -// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape. -export function PlexPersonView({ - name, - onBack, - onOpenCard, - onInfoCard, - onFilter, -}: { - name: string; - onBack: () => void; - onOpenCard: (card: PlexCard) => void; - onInfoCard: (card: PlexCard) => void; - onFilter: (patch: Partial) => void; -}) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) }); - - // Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload - // (both the movies and shows arrays — an id matches only one), then persist + resync via the shared - // helper; also invalidate the library so its grid agrees. - const toggle = (card: PlexCard) => - toggleWatchState(card, t, { - optimistic: (next) => - qc.setQueryData(qk.plexPerson(name), (old) => - old - ? { - ...old, - movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)), - shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)), - } - : old, - ), - resync: () => { - qc.invalidateQueries({ queryKey: qk.plexPerson(name) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - }, - }); - - const section = (titleKey: string, cards: PlexCard[]) => - cards.length > 0 ? ( -
-

{t(titleKey, { count: cards.length })}

-
- {cards.map((c) => ( - onOpenCard(c)} - onInfo={() => onInfoCard(c)} - onToggleWatched={toggle} - /> - ))} -
-
- ) : null; - - const d = q.data; - const empty = !!d && d.movies.length === 0 && d.shows.length === 0; - return ( -
-
- -
- {q.isError && !d ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !d ? ( - - ) : ( -
- {/* Hero: headshot + name + a "filter the library by this person" action. */} -
-
- {d.photo ? ( - - ) : ( -
- -
- )} -
-
-

{d.name}

-

- {t("plex.person.credits", { - movies: d.movies.length, - shows: d.shows.length, - })} -

- -
-
- {empty ? ( -

{t("plex.person.empty")}

- ) : ( - <> - {section("plex.person.movies", d.movies)} - {section("plex.person.shows", d.shows)} - - )} -
- )} -
- ); -} - -// --- Series drill-down: show detail page → season subpage → player ------------------------------- -// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the -// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's -// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start), -// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only — -// Add the whole show to a collection. - -function epLabel(ep?: PlexCard | null): string | undefined { - if (!ep) return undefined; - if (ep.season_number != null && ep.episode_number != null) - return `S${ep.season_number} · E${ep.episode_number}`; - return ep.title; -} - -function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { - return ( - - ); -} - -function ActionBtn({ - onClick, - icon: Icon, - label, - sub, - primary, - disabled, -}: { - onClick: () => void; - icon: LucideIcon; - label: string; - sub?: string; - primary?: boolean; - disabled?: boolean; -}) { - return ( - - ); -} - -function SeasonCard({ - se, - onOpen, - onToggleWatched, - onAdd, -}: { - se: PlexSeasonDetail; - onOpen: () => void; - onToggleWatched?: () => void; - onAdd?: () => void; -}) { - const { t } = useTranslation(); - const watched = se.status === "watched"; - return ( -
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onOpen(); - } - }} - className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" - > - -
-
- - {t("plex.openShow")} -
-
- {/* Quick watched toggle (whole season), on hover. */} - {onToggleWatched && ( - - )} - {/* Add whole season to a playlist, on hover. */} - {onAdd && ( - - )} - {watched && ( - - {t("plex.watched")} - - )} - {se.status === "in_progress" && ( - - {t("plex.inProgress")} - - )} - - {t("plex.series.episodeCount", { count: se.episode_count })} - -
-
- {se.title} -
-
- ); -} - -export function CastStrip({ - cast, - onFilter, - onOpenPerson, -}: { - cast: PlexCastMember[]; - onFilter?: (patch: Partial) => void; - onOpenPerson?: (name: string) => void; -}) { - const { t } = useTranslation(); - const clickable = !!(onOpenPerson || onFilter); - return ( -
-

{t("plex.info.cast")}

-
- {cast.map((c, i) => { - const inner = ( - <> -
- {c.thumb ? ( - - ) : ( -
- {c.name.charAt(0)} -
- )} -
-
- {c.name} -
- {c.role &&
{c.role}
} - - ); - return clickable ? ( - - ) : ( -
- {inner} -
- ); - })} -
-
- ); -} - -function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) { - const { t } = useTranslation(); - return ( -
-

{t("plex.series.related")}

-
- {related.map((r) => ( - - ))} -
-
- ); -} - -function EpisodeCard({ - ep, - onPlay, - onAdd, - onToggleWatched, - withShowTitle, -}: { - ep: PlexCard; - onPlay: () => void; - onAdd?: () => void; - onToggleWatched?: () => void; - withShowTitle?: boolean; -}) { - const { t } = useTranslation(); - const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; - const pct = - inProgress && ep.duration_seconds - ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) - : 0; - return ( -
-
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onPlay(); - } - }} - className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" - > - -
- -
- {/* Quick watched toggle (single episode), on hover. */} - {onToggleWatched && ( - - )} - {ep.status === "watched" && ( - - - - )} - {pct > 0 && ( -
-
-
- )} -
-
-
- {withShowTitle && ep.show_title && ( -
- {ep.show_title} - {ep.season_number != null && ep.episode_number != null - ? ` · S${ep.season_number}·E${ep.episode_number}` - : ""} -
- )} -
- {!withShowTitle && {ep.episode_number}.} - {ep.title} -
-
{formatRuntime(ep.duration_seconds)}
-
- {onAdd && ( - - )} -
-
- ); -} - -/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were - * copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot, - * and the flex info column; the caller passes the customize menu + the title/metadata/actions as - * `children`. Poster is slightly larger on the show page (default) than the season page. */ -function DetailHero({ - thumb, - posterTitle, - posterDisabled, - showPlayOverlay, - onPosterClick, - posterSizeClass = "w-32 sm:w-44", - customizeMenu, - children, -}: { - thumb: string; - posterTitle: string; - posterDisabled: boolean; - showPlayOverlay: boolean; - onPosterClick: () => void; - posterSizeClass?: string; - customizeMenu: ReactNode; - children: ReactNode; -}) { - return ( -
-
{customizeMenu}
- -
{children}
-
- ); -} - -/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both - * the show and season heroes; the labels differ, so the caller passes both. */ -function WatchedToggle({ - watched, - busy, - onToggle, - watchedLabel, - unwatchedLabel, -}: { - watched: boolean; - busy?: boolean; - onToggle: () => void; - watchedLabel: string; - unwatchedLabel: string; -}) { - return ( - - ); -} - -function PlexShowView({ - showId, - onBack, - onPlay, - onOpenSeason, - onOpenShow, - onFilter, - onOpenPerson, -}: { - showId: string; - onBack: () => void; - onPlay: (epRk: string, queue: string[]) => void; - onOpenSeason: (seasonId: string) => void; - onOpenShow: (id: string) => void; - onFilter?: (patch: Partial) => void; - onOpenPerson?: (name: string) => void; -}) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; - const lang = plexLang(); - const q = useQuery({ - queryKey: qk.plexShow(showId, lang), - queryFn: () => api.plexShow(showId, lang), - }); - const d = q.data; - const [addTarget, setAddTarget] = useState(null); - const [collOpen, setCollOpen] = useState(false); - const [busy, setBusy] = useState(false); - - const show = d?.show; - const showLib = show?.library ?? undefined; - const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id)); - const watched = show?.status === "watched"; - const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs(); - useArtBackdrop(show?.art, artBg); - async function markAll(w: boolean) { - setBusy(true); - try { - await api.plexShowState(showId, w); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } finally { - setBusy(false); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - async function markSeason(seasonRk: string, w: boolean) { - try { - await api.plexSeasonState(seasonRk, w); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - - return ( -
- - - {q.isError && !q.data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !d || !show ? ( - - ) : ( - <> - {/* Hero */} - show.resume && onPlay(show.resume.id, allKeys)} - customizeMenu={ - 0} - showRelated={showRelated} - onToggleRelated={toggleRelated} - hasRelated={d.related.length > 0} - /> - } - > -

{show.title}

-
- {show.year != null && ( - onFilter({ yearMin: show.year, yearMax: show.year }) - : undefined - } - > - {show.year} - - )} - {show.content_rating && ( - onFilter({ contentRatings: [show.content_rating!] }) - : undefined - } - > - · {show.content_rating} - - )} - {show.season_count != null && ( - · {t("plex.seasons", { count: show.season_count })} - )} - {(() => { - // IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show - // with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a - // rating instead of dropping the badge entirely. - const badge = pickRatingBadge(show.imdb_rating, show.rating); - if (!badge) return null; - const { score } = badge; - return ( - - ); - })()} -
- {show.genres.length > 0 && ( -
- {show.genres.map((g) => ( - onFilter({ genres: [g] }) : undefined} - > - {g} - - ))} -
- )} - {show.summary && ( -

{show.summary}

- )} -
- {show.resume && ( - onPlay(show.resume!.id, allKeys)} - icon={Play} - label={show.status === "new" ? t("plex.play") : t("plex.resume")} - sub={epLabel(show.resume)} - /> - )} - {show.first && show.status !== "new" && ( - onPlay(show.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} - markAll(!watched)} - watchedLabel={t("plex.series.markShowUnwatched")} - unwatchedLabel={t("plex.series.markShowWatched")} - /> - {allKeys.length > 0 && ( - - setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) - } - icon={ListPlus} - label={t("plex.playlist.addShow")} - /> - )} - {isAdmin && showLib && ( - setCollOpen(true)} - icon={Layers} - label={t("plex.series.addShowCollection")} - /> - )} -
-
- - {/* Seasons — in a glassy panel like the movie info-page strips. */} -
-

{t("plex.series.seasons")}

-
- {d.seasons.map((se) => ( - onOpenSeason(se.id)} - onToggleWatched={() => markSeason(se.id, se.status !== "watched")} - onAdd={ - se.episodes.length > 0 - ? () => - setAddTarget({ - kind: "group", - ratingKeys: se.episodes.map((e) => e.id), - title: `${show.title} — ${se.title}`, - }) - : undefined - } - /> - ))} -
-
- - {show.cast.length > 0 && showCast && ( - - )} - {d.related.length > 0 && showRelated && ( - - )} - - )} - - {addTarget && setAddTarget(null)} />} - {collOpen && show && showLib && ( - setCollOpen(false)} - onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })} - /> - )} -
- ); -} - -function PlexSeasonView({ - showId, - seasonId, - onBack, - onPlay, -}: { - showId: string; - seasonId: string; - onBack: () => void; - onPlay: (epRk: string, queue: string[]) => void; -}) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const lang = plexLang(); - const q = useQuery({ - queryKey: qk.plexShow(showId, lang), - queryFn: () => api.plexShow(showId, lang), - }); - const d = q.data; - const se = d?.seasons.find((s) => s.id === seasonId); - const [addTarget, setAddTarget] = useState(null); - const [busy, setBusy] = useState(false); - - const seasonKeys = se?.episodes.map((e) => e.id) ?? []; - // Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls - // straight into the next season's first — the same cross-season order the show page already uses. - // seasonKeys stays only for the season-scoped "add to playlist" action. - const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id)); - const watched = se?.status === "watched"; - const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs(); - useArtBackdrop(d?.show.art, artBg); - async function markAll(w: boolean) { - setBusy(true); - try { - await api.plexSeasonState(seasonId, w); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } finally { - setBusy(false); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - async function markEpisode(ep: PlexCard) { - try { - await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched"); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } - qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } - - return ( -
- - - {q.isError && !q.data ? ( - q.refetch()}> - {t("common.loadError")} - - ) : q.isLoading || !d || !se ? ( - - ) : ( - <> - se.resume && onPlay(se.resume.id, allKeys)} - posterSizeClass="w-28 sm:w-40" - customizeMenu={ - - } - > - -

{se.title}

-

- {t("plex.series.episodeCount", { count: se.episode_count })} -

-
- {se.resume && ( - onPlay(se.resume!.id, allKeys)} - icon={Play} - label={se.status === "new" ? t("plex.play") : t("plex.resume")} - sub={epLabel(se.resume)} - /> - )} - {se.first && se.status !== "new" && ( - onPlay(se.first!.id, allKeys)} - icon={RotateCcw} - label={t("plex.series.playFromStart")} - /> - )} - markAll(!watched)} - watchedLabel={t("plex.series.markSeasonUnwatched")} - unwatchedLabel={t("plex.series.markSeasonWatched")} - /> - {seasonKeys.length > 0 && ( - - setAddTarget({ - kind: "group", - ratingKeys: seasonKeys, - title: `${d.show.title} — ${se.title}`, - }) - } - icon={ListPlus} - label={t("plex.playlist.addSeason")} - /> - )} -
-
- -
- {se.episodes.map((ep) => ( - onPlay(ep.id, allKeys)} - onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} - onToggleWatched={() => markEpisode(ep)} - /> - ))} -
- - )} - - {addTarget && setAddTarget(null)} />} -
- ); -} diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index 6a70be91..f91c2b0e 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -25,7 +25,8 @@ vi.mock("../lib/api", () => ({ plexFilterCount: () => 0, })); -import { CastStrip, PlexPersonView } from "./PlexBrowse"; +import { CastStrip } from "./plexCards"; +import { PlexPersonView } from "./plexViews"; afterEach(cleanup); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx new file mode 100644 index 00000000..1e2b7b84 --- /dev/null +++ b/frontend/src/components/plexCards.tsx @@ -0,0 +1,463 @@ +import { useTranslation } from "react-i18next"; +import { CheckCircle2, Film, Info, ListPlus, Play, Tv2 } from "lucide-react"; +import { type CSSProperties } from "react"; +import { formatRuntime } from "../lib/format"; +import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; + +// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared +// state). Rendered by the browse grid, the person page, and the series show/season views. + +// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage +// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient; +// all three stay visually distinct. +const PLAYABLE_TINT: Record = { + direct: "bg-success", + remux: "bg-warn", + transcode: "bg-danger", +}; + +export function PlexPosterCard({ + card, + onOpen, + onInfo, + onToggleWatched, +}: { + card: PlexCard; + onOpen: () => void; + onInfo: () => void; + onToggleWatched: (c: PlexCard) => void; +}) { + const { t } = useTranslation(); + const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; + const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down + const pct = + inProgress && card.duration_seconds + ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) + : 0; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" + > + + {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */} +
+
+ + + {card.type === "show" + ? t("plex.openShow") + : inProgress + ? t("plex.resume") + : t("plex.play")} + +
+
+ {/* Quick watched toggle (movies/episodes), on hover. */} + {isPlayable && ( + + )} + {/* Media info page (movies/episodes). */} + {isPlayable && ( + + )} + {/* Watched badge when not hovering (the toggle replaces it on hover). */} + {card.status === "watched" && ( + + {t("plex.watched")} + + )} + {card.type === "show" && card.season_count != null && ( + + {t("plex.seasons", { count: card.season_count })} + + )} + {/* Aggregate in-progress badge for a partially-watched show. */} + {card.type === "show" && card.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} + {card.playable && ( + + )} + {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} + + {card.type === "show" ? : } + + {pct > 0 && ( +
+
+
+ )} +
+
+
+ {card.title} +
+
+ {[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")} +
+
+
+ ); +} + +export function SeasonCard({ + se, + onOpen, + onToggleWatched, + onAdd, +}: { + se: PlexSeasonDetail; + onOpen: () => void; + onToggleWatched?: () => void; + onAdd?: () => void; +}) { + const { t } = useTranslation(); + const watched = se.status === "watched"; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className="glass-card glass-hover relative aspect-2/3 rounded-xl overflow-hidden transition-all duration-200 cursor-pointer" + > + +
+
+ + {t("plex.openShow")} +
+
+ {/* Quick watched toggle (whole season), on hover. */} + {onToggleWatched && ( + + )} + {/* Add whole season to a playlist, on hover. */} + {onAdd && ( + + )} + {watched && ( + + {t("plex.watched")} + + )} + {se.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} + + {t("plex.series.episodeCount", { count: se.episode_count })} + +
+
+ {se.title} +
+
+ ); +} + +export function CastStrip({ + cast, + onFilter, + onOpenPerson, +}: { + cast: PlexCastMember[]; + onFilter?: (patch: Partial) => void; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + const clickable = !!(onOpenPerson || onFilter); + return ( +
+

{t("plex.info.cast")}

+
+ {cast.map((c, i) => { + const inner = ( + <> +
+ {c.thumb ? ( + + ) : ( +
+ {c.name.charAt(0)} +
+ )} +
+
+ {c.name} +
+ {c.role &&
{c.role}
} + + ); + return clickable ? ( + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ ); +} + +export function RelatedStrip({ + related, + onOpen, +}: { + related: PlexCard[]; + onOpen: (id: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+

{t("plex.series.related")}

+
+ {related.map((r) => ( + + ))} +
+
+ ); +} + +export function EpisodeCard({ + ep, + onPlay, + onAdd, + onToggleWatched, + withShowTitle, +}: { + ep: PlexCard; + onPlay: () => void; + onAdd?: () => void; + onToggleWatched?: () => void; + withShowTitle?: boolean; +}) { + const { t } = useTranslation(); + const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; + const pct = + inProgress && ep.duration_seconds + ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) + : 0; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onPlay(); + } + }} + className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" + > + +
+ +
+ {/* Quick watched toggle (single episode), on hover. */} + {onToggleWatched && ( + + )} + {ep.status === "watched" && ( + + + + )} + {pct > 0 && ( +
+
+
+ )} +
+
+
+ {withShowTitle && ep.show_title && ( +
+ {ep.show_title} + {ep.season_number != null && ep.episode_number != null + ? ` · S${ep.season_number}·E${ep.episode_number}` + : ""} +
+ )} +
+ {!withShowTitle && {ep.episode_number}.} + {ep.title} +
+
{formatRuntime(ep.duration_seconds)}
+
+ {onAdd && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx new file mode 100644 index 00000000..c89071eb --- /dev/null +++ b/frontend/src/components/plexViews.tsx @@ -0,0 +1,615 @@ +import { lazy, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Layers, ListPlus, Play, RotateCcw, Star, User } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { api, type PlexCard, type PlexFilters, type PlexPersonDetail } from "../lib/api"; +import { plexLang } from "../i18n"; +import { pickRatingBadge } from "../lib/plexRating"; +import { notifyActionError } from "../lib/actionErrors"; +import { toggleWatchState } from "../lib/plexWatch"; +import { + ActionBtn, + BackBtn, + DetailCustomizeMenu, + DetailHero, + epLabel, + Filterable, + useArtBackdrop, + useDetailPrefs, + WatchedToggle, +} from "../lib/plexDetailUi"; +import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; +import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; +import PlexCollectionEditor from "./PlexCollectionEditor"; +import { LoadingState, StateMessage } from "./QueryState"; + +// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with +// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, +// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared +// detail-page UI from lib/plexDetailUi. + +// Lazy: the rich movie info panel loads only when an info page is opened. +const PlexInfo = lazy(() => import("./PlexInfo")); + +export function PlexInfoView({ + id, + onBack, + onPlay, + onPlayItem, + onFilter, + onOpenPerson, +}: { + id: string; + onBack: () => void; + onPlay: () => void; + onPlayItem: (id: string) => void; + onFilter: (patch: Partial) => void; + onOpenPerson: (name: string) => void; +}) { + const { t } = useTranslation(); + const lang = plexLang(); + const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) }); + return ( +
+
+ +
+ {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !q.data ? ( + + ) : ( + {t("plex.loading")}

}> + +
+ )} +
+ ); +} + +// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) --------------- +// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it +// shows the person's headshot + name, a "filter the library by them" action, and their titles as two +// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape. +export function PlexPersonView({ + name, + onBack, + onOpenCard, + onInfoCard, + onFilter, +}: { + name: string; + onBack: () => void; + onOpenCard: (card: PlexCard) => void; + onInfoCard: (card: PlexCard) => void; + onFilter: (patch: Partial) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) }); + + // Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload + // (both the movies and shows arrays — an id matches only one), then persist + resync via the shared + // helper; also invalidate the library so its grid agrees. + const toggle = (card: PlexCard) => + toggleWatchState(card, t, { + optimistic: (next) => + qc.setQueryData(qk.plexPerson(name), (old) => + old + ? { + ...old, + movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)), + shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)), + } + : old, + ), + resync: () => { + qc.invalidateQueries({ queryKey: qk.plexPerson(name) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + }, + }); + + const section = (titleKey: string, cards: PlexCard[]) => + cards.length > 0 ? ( +
+

{t(titleKey, { count: cards.length })}

+
+ {cards.map((c) => ( + onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={toggle} + /> + ))} +
+
+ ) : null; + + const d = q.data; + const empty = !!d && d.movies.length === 0 && d.shows.length === 0; + return ( +
+
+ +
+ {q.isError && !d ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d ? ( + + ) : ( +
+ {/* Hero: headshot + name + a "filter the library by this person" action. */} +
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )} +
+
+

{d.name}

+

+ {t("plex.person.credits", { + movies: d.movies.length, + shows: d.shows.length, + })} +

+ +
+
+ {empty ? ( +

{t("plex.person.empty")}

+ ) : ( + <> + {section("plex.person.movies", d.movies)} + {section("plex.person.shows", d.shows)} + + )} +
+ )} +
+ ); +} + +// --- Series drill-down: show detail page → season subpage → player ------------------------------- +// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the +// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's +// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start), +// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only — +// Add the whole show to a collection. + +export function PlexShowView({ + showId, + onBack, + onPlay, + onOpenSeason, + onOpenShow, + onFilter, + onOpenPerson, +}: { + showId: string; + onBack: () => void; + onPlay: (epRk: string, queue: string[]) => void; + onOpenSeason: (seasonId: string) => void; + onOpenShow: (id: string) => void; + onFilter?: (patch: Partial) => void; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; + const lang = plexLang(); + const q = useQuery({ + queryKey: qk.plexShow(showId, lang), + queryFn: () => api.plexShow(showId, lang), + }); + const d = q.data; + const [addTarget, setAddTarget] = useState(null); + const [collOpen, setCollOpen] = useState(false); + const [busy, setBusy] = useState(false); + + const show = d?.show; + const showLib = show?.library ?? undefined; + const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id)); + const watched = show?.status === "watched"; + const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs(); + useArtBackdrop(show?.art, artBg); + async function markAll(w: boolean) { + setBusy(true); + try { + await api.plexShowState(showId, w); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } finally { + setBusy(false); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + async function markSeason(seasonRk: string, w: boolean) { + try { + await api.plexSeasonState(seasonRk, w); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + + return ( +
+ + + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !show ? ( + + ) : ( + <> + {/* Hero */} + show.resume && onPlay(show.resume.id, allKeys)} + customizeMenu={ + 0} + showRelated={showRelated} + onToggleRelated={toggleRelated} + hasRelated={d.related.length > 0} + /> + } + > +

{show.title}

+
+ {show.year != null && ( + onFilter({ yearMin: show.year, yearMax: show.year }) + : undefined + } + > + {show.year} + + )} + {show.content_rating && ( + onFilter({ contentRatings: [show.content_rating!] }) + : undefined + } + > + · {show.content_rating} + + )} + {show.season_count != null && ( + · {t("plex.seasons", { count: show.season_count })} + )} + {(() => { + // IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show + // with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a + // rating instead of dropping the badge entirely. + const badge = pickRatingBadge(show.imdb_rating, show.rating); + if (!badge) return null; + const { score } = badge; + return ( + + ); + })()} +
+ {show.genres.length > 0 && ( +
+ {show.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + {show.summary && ( +

{show.summary}

+ )} +
+ {show.resume && ( + onPlay(show.resume!.id, allKeys)} + icon={Play} + label={show.status === "new" ? t("plex.play") : t("plex.resume")} + sub={epLabel(show.resume)} + /> + )} + {show.first && show.status !== "new" && ( + onPlay(show.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markShowUnwatched")} + unwatchedLabel={t("plex.series.markShowWatched")} + /> + {allKeys.length > 0 && ( + + setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title }) + } + icon={ListPlus} + label={t("plex.playlist.addShow")} + /> + )} + {isAdmin && showLib && ( + setCollOpen(true)} + icon={Layers} + label={t("plex.series.addShowCollection")} + /> + )} +
+
+ + {/* Seasons — in a glassy panel like the movie info-page strips. */} +
+

{t("plex.series.seasons")}

+
+ {d.seasons.map((se) => ( + onOpenSeason(se.id)} + onToggleWatched={() => markSeason(se.id, se.status !== "watched")} + onAdd={ + se.episodes.length > 0 + ? () => + setAddTarget({ + kind: "group", + ratingKeys: se.episodes.map((e) => e.id), + title: `${show.title} — ${se.title}`, + }) + : undefined + } + /> + ))} +
+
+ + {show.cast.length > 0 && showCast && ( + + )} + {d.related.length > 0 && showRelated && ( + + )} + + )} + + {addTarget && setAddTarget(null)} />} + {collOpen && show && showLib && ( + setCollOpen(false)} + onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })} + /> + )} +
+ ); +} + +export function PlexSeasonView({ + showId, + seasonId, + onBack, + onPlay, +}: { + showId: string; + seasonId: string; + onBack: () => void; + onPlay: (epRk: string, queue: string[]) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const lang = plexLang(); + const q = useQuery({ + queryKey: qk.plexShow(showId, lang), + queryFn: () => api.plexShow(showId, lang), + }); + const d = q.data; + const se = d?.seasons.find((s) => s.id === seasonId); + const [addTarget, setAddTarget] = useState(null); + const [busy, setBusy] = useState(false); + + const seasonKeys = se?.episodes.map((e) => e.id) ?? []; + // Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls + // straight into the next season's first — the same cross-season order the show page already uses. + // seasonKeys stays only for the season-scoped "add to playlist" action. + const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id)); + const watched = se?.status === "watched"; + const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs(); + useArtBackdrop(d?.show.art, artBg); + async function markAll(w: boolean) { + setBusy(true); + try { + await api.plexSeasonState(seasonId, w); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } finally { + setBusy(false); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + async function markEpisode(ep: PlexCard) { + try { + await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched"); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } + qc.invalidateQueries({ queryKey: qk.plexShow(showId) }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + } + + return ( +
+ + + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d || !se ? ( + + ) : ( + <> + se.resume && onPlay(se.resume.id, allKeys)} + posterSizeClass="w-28 sm:w-40" + customizeMenu={ + + } + > + +

{se.title}

+

+ {t("plex.series.episodeCount", { count: se.episode_count })} +

+
+ {se.resume && ( + onPlay(se.resume!.id, allKeys)} + icon={Play} + label={se.status === "new" ? t("plex.play") : t("plex.resume")} + sub={epLabel(se.resume)} + /> + )} + {se.first && se.status !== "new" && ( + onPlay(se.first!.id, allKeys)} + icon={RotateCcw} + label={t("plex.series.playFromStart")} + /> + )} + markAll(!watched)} + watchedLabel={t("plex.series.markSeasonUnwatched")} + unwatchedLabel={t("plex.series.markSeasonWatched")} + /> + {seasonKeys.length > 0 && ( + + setAddTarget({ + kind: "group", + ratingKeys: seasonKeys, + title: `${d.show.title} — ${se.title}`, + }) + } + icon={ListPlus} + label={t("plex.playlist.addSeason")} + /> + )} +
+
+ +
+ {se.episodes.map((ep) => ( + onPlay(ep.id, allKeys)} + onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} + onToggleWatched={() => markEpisode(ep)} + /> + ))} +
+ + )} + + {addTarget && setAddTarget(null)} />} +
+ ); +} diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 328bf66c..cf4b0199 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -1,8 +1,16 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { SlidersHorizontal } from "lucide-react"; +import { + ArrowLeft, + Check, + CheckCheck, + Play, + SlidersHorizontal, + type LucideIcon, +} from "lucide-react"; import { useDismiss } from "./useDismiss"; import { usePref, useSavePref } from "./usePref"; +import type { PlexCard } from "./api"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -184,3 +192,130 @@ export function PrefToggle({ ); } + +// --- Series detail-page shared UI (the show page + the season subpage share these) ------------------- +// Moved out of PlexBrowse — small building blocks the show/season detail views render verbatim. + +export function epLabel(ep?: PlexCard | null): string | undefined { + if (!ep) return undefined; + if (ep.season_number != null && ep.episode_number != null) + return `S${ep.season_number} · E${ep.episode_number}`; + return ep.title; +} + +export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) { + return ( + + ); +} + +export function ActionBtn({ + onClick, + icon: Icon, + label, + sub, + primary, + disabled, +}: { + onClick: () => void; + icon: LucideIcon; + label: string; + sub?: string; + primary?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} + +/** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were + * copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot, + * and the flex info column; the caller passes the customize menu + the title/metadata/actions as + * `children`. Poster is slightly larger on the show page (default) than the season page. */ +export function DetailHero({ + thumb, + posterTitle, + posterDisabled, + showPlayOverlay, + onPosterClick, + posterSizeClass = "w-32 sm:w-44", + customizeMenu, + children, +}: { + thumb: string; + posterTitle: string; + posterDisabled: boolean; + showPlayOverlay: boolean; + onPosterClick: () => void; + posterSizeClass?: string; + customizeMenu: ReactNode; + children: ReactNode; +}) { + return ( +
+
{customizeMenu}
+ +
{children}
+
+ ); +} + +/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both + * the show and season heroes; the labels differ, so the caller passes both. */ +export function WatchedToggle({ + watched, + busy, + onToggle, + watchedLabel, + unwatchedLabel, +}: { + watched: boolean; + busy?: boolean; + onToggle: () => void; + watchedLabel: string; + unwatchedLabel: string; +}) { + return ( + + ); +} diff --git a/frontend/src/lib/plexWatch.ts b/frontend/src/lib/plexWatch.ts new file mode 100644 index 00000000..2ba8afd0 --- /dev/null +++ b/frontend/src/lib/plexWatch.ts @@ -0,0 +1,22 @@ +import type { TFunction } from "i18next"; +import { api, type PlexCard } from "./api"; +import { notifyActionError } from "./actionErrors"; + +// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache +// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then +// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the +// browse grid and the person page, each of which owns a different cache shape. +export async function toggleWatchState( + card: PlexCard, + t: TFunction, + handlers: { optimistic: (next: string) => void; resync: () => void }, +) { + const next = card.status === "watched" ? "new" : "watched"; + handlers.optimistic(next); + try { + await api.plexSetState(card.id, next); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } + handlers.resync(); +} From aea99cc71ae241ddf2de058c61662a7c282373d7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 21:14:49 +0200 Subject: [PATCH 06/11] refactor(downloads): split DownloadCenter god-file into row + modals + admin (E4 S5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure code-motion, DownloadCenter 1088->359 L. Group by kind, mirroring the S4 plexCards/plexViews split: - components/downloadRow.tsx (309 L): the user-list presentation + shared row primitives — StatusPill, Thumb, IconBtn, SourceRef, ChannelLine, DownloadRow, UsageBar (+ jobTitle/jobChannel/displayUrl helpers, status/phase constants). - components/downloadModals.tsx (178 L): the per-item user modals — MetaEditModal (rename/metadata) and ErrorModal (failure detail + retry). - components/downloadAdmin.tsx (267 L): the admin "System" tab — AdminSystem plus QuotaModal and QuotaUserPicker. Every extracted piece is prop-driven with no shell-state reach-back, so the shell just imports them back; the tab logic, mutations and modal state stay in DownloadCenter. Behaviour-preserving. Gate green (tsc first try, knip, lint 0-err, 247 vitest). Self-E2E (localdev): Queue/Library/Shared/System tabs render, UsageBar, admin storage/footprint/job list, MetaEditModal open/close, 0 console errors. --- frontend/src/components/DownloadCenter.tsx | 737 +-------------------- frontend/src/components/downloadAdmin.tsx | 267 ++++++++ frontend/src/components/downloadModals.tsx | 178 +++++ frontend/src/components/downloadRow.tsx | 309 +++++++++ 4 files changed, 758 insertions(+), 733 deletions(-) create mode 100644 frontend/src/components/downloadAdmin.tsx create mode 100644 frontend/src/components/downloadModals.tsx create mode 100644 frontend/src/components/downloadRow.tsx diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 8f3c6b14..386d6248 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,14 +1,11 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, - CalendarClock, ClipboardPaste, - Copy, Download, - Link2, Pause, Play, Plus, @@ -19,11 +16,9 @@ import { Pencil, Trash2, } from "lucide-react"; -import clsx from "clsx"; import Tabs from "./Tabs"; import { PageToolbar } from "./PageShell"; import { LoadingState, StateMessage } from "./QueryState"; -import Modal from "./Modal"; // Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until // used (the video editor in particular is heavy — filmstrip + scrubber). const DownloadDialog = lazy(() => import("./DownloadDialog")); @@ -35,734 +30,10 @@ import { useLiveQuery } from "../lib/useLiveQuery"; import { useMe } from "../lib/useMe"; import { api, type DownloadJob } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; -import { notify } from "../lib/notifications"; -import { copyText } from "../lib/clipboard"; -import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; import { inputCls } from "./ui/form"; - -const GiB = 1024 ** 3; - -const STATUS_CLS: Record = { - queued: "text-muted", - running: "text-accent", - paused: "text-warn", - done: "text-success", - error: "text-danger", - canceled: "text-muted", -}; - -// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse). -// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar. -const DOWNLOAD_PHASES = ["video", "audio", "editing"]; -const PHASE_KEYS = [ - "video", - "audio", - "editing", - "merging", - "audio_extract", - "thumbnail", - "sponsorblock", - "metadata", - "processing", -]; - -function StatusPill({ job }: { job: DownloadJob }) { - const { t } = useTranslation(); - // While downloading, show the concrete phase (video / audio / merging) so the user isn't - // confused by the bar resetting between the separate video and audio streams. - if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) { - return ( - {t(`downloads.phase.${job.phase}`)} - ); - } - const key = job.expired ? "expired" : job.status; - return ( - - {t(`downloads.status.${key}`)} - - ); -} - -function Thumb({ job }: { job: DownloadJob }) { - // Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so - // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields - // rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for - // the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed). - const remote = job.asset?.thumbnail_url ?? null; - // `job.poster_url` only SIGNALS a poster exists; the actual src must carry ?account= (see - // api.posterImageUrl). A plain can't send the X-Siftlode-Account header, so without it the - // poster resolves to the session-default account and 404s for a per-tab account's download — which, - // now that the poster is the primary image, reads as a blank Library thumbnail. - const poster = job.poster_url ? api.posterImageUrl(job.id) : null; - const primary = poster || remote; - // On load failure (a genuinely missing poster file) fall back to the remote source thumbnail. - const [src, setSrc] = useState(primary); - useEffect(() => setSrc(primary), [primary]); - return ( -
- {src ? ( - { - if (remote && src !== remote) setSrc(remote); - }} - /> - ) : null} -
- ); -} - -function jobTitle(job: DownloadJob): string { - return job.display_name || job.asset?.title || job.source_ref; -} - -// The channel to show under the title: a user override wins over the auto-extracted asset value. -// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. -function jobChannel(job: DownloadJob): { name: string; url: string | null } { - return { - name: job.display_uploader || job.asset?.uploader || job.source_ref, - url: job.display_uploader_url || job.asset?.uploader_url || null, - }; -} - -function IconBtn({ - onClick, - title, - danger, - children, -}: { - onClick: () => void; - title: string; - danger?: boolean; - children: React.ReactNode; -}) { - return ( - - ); -} - -// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. -function displayUrl(url: string): string { - try { - const u = new URL(url); - return (u.host + u.pathname + u.search).replace(/\/$/, ""); - } catch { - return url; - } -} - -// "Downloaded from" reference: opens the original page in a new tab, or copies the link. -function SourceRef({ url }: { url: string }) { - const { t } = useTranslation(); - const copy = async () => { - if (await copyText(url)) { - notify({ level: "success", message: t("downloads.source.copied") }); - } else { - notify({ level: "error", message: t("downloads.source.copyFailed") }); - } - }; - return ( - - ); -} - -// Channel under the title — a clickable link when a channel URL is known, plain text otherwise. -function ChannelLine({ job }: { job: DownloadJob }) { - const { name, url } = jobChannel(job); - if (url) { - return ( - - ); - } - return
{name}
; -} - -function DownloadRow({ - job, - onShowError, - children, -}: { - job: DownloadJob; - onShowError?: (job: DownloadJob) => void; - children?: React.ReactNode; -}) { - const { t } = useTranslation(); - const running = job.status === "running"; - return ( -
- -
-
- {job.job_kind === "edit" && ( - - {t("downloads.clipBadge")} - - )} - {jobTitle(job)} -
- -
- - {job.asset?.size_bytes ? ( - · {formatBytes(job.asset.size_bytes)} - ) : null} - {job.asset?.duration_s ? ( - · {formatDuration(job.asset.duration_s)} - ) : null} - {job.status === "done" && job.created_at ? ( - - · {relativeTime(job.created_at)} - - ) : null} - {job.status === "error" ? ( - - ) : null} -
- {job.source_url && } - {job.extra_links?.map((url) => ( - - ))} - {running && ( -
- {job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( - // ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %. -
-
-
- ) : ( - <> -
-
-
-
- {job.progress}% - {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} - {job.eta_s ? {formatEta(job.eta_s)} : null} -
- - )} -
- )} -
-
{children}
-
- ); -} - -// --- Rename + Share small modals ------------------------------------------------------------ - -// Edit a download's display metadata: title, channel (name + link), and extra reference links. -// All are display-only overrides — the on-disk file is never renamed. -function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const [name, setName] = useState(job.display_name || job.asset?.title || ""); - const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || ""); - const [channelUrl, setChannelUrl] = useState( - job.display_uploader_url || job.asset?.uploader_url || "", - ); - // Editable extra-link rows; keep one blank row so there's always somewhere to type. - const [links, setLinks] = useState(() => [...(job.extra_links ?? []), ""]); - - const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l))); - const addLink = () => setLinks((ls) => [...ls, ""]); - const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i)); - - const save = useMutation({ - mutationFn: () => - api.updateDownloadMeta(job.id, { - display_name: name.trim(), - display_uploader: channel.trim(), - display_uploader_url: channelUrl.trim(), - extra_links: links.map((l) => l.trim()).filter(Boolean), - }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.downloads() }); - onClose(); - }, - }); - - return ( - -
- - - -
- {t("downloads.edit.extraLinks")} -
- {links.map((l, i) => ( -
- setLink(i, e.target.value)} - placeholder="https://…" - className={inputCls} - /> - removeLink(i)} - title={t("downloads.edit.removeLink")} - danger - > - - -
- ))} -
- -
-

{t("downloads.edit.hint")}

-
- -
-
-
- ); -} - -// --- Error detail modal --------------------------------------------------------------------- - -// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…), -// where a retry can't help — the queue row still keeps its own retry control regardless. -const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"]; - -// A readable "why did this fail" window for a failed job: a category headline + plain explanation, -// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient. -function ErrorModal({ - job, - onRetry, - onClose, -}: { - job: DownloadJob; - onRetry: () => void; - onClose: () => void; -}) { - const { t } = useTranslation(); - const code = job.error_code || "unknown"; - const [showRaw, setShowRaw] = useState(false); - const retryable = RETRYABLE_ERRORS.includes(code); - return ( - -
-
{t(`downloads.errors.fail.cat.${code}.short`)}
-

{t(`downloads.errors.fail.cat.${code}.body`)}

- {job.error && ( -
- - {showRaw && ( -
-                {job.error}
-              
- )} -
- )} -
- {retryable && ( - - )} - -
-
-
- ); -} - -// --- Usage bar ------------------------------------------------------------------------------ - -function UsageBar() { - const { t } = useTranslation(); - // Poll live (like the library list) so the footprint updates the moment the worker finishes an - // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so - // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. - const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 }); - const u = usage.data; - if (!u) return null; - const pct = - u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); - return ( -
-
- {t("downloads.usage.title")} - - {formatBytes(u.footprint_bytes)} - {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "} - {u.unlimited - ? t("downloads.usage.unlimited") - : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })} - -
- {!u.unlimited && ( -
-
90 ? "bg-danger" : "bg-accent")} - style={{ width: `${pct}%` }} - /> -
- )} -
- ); -} - -// --- Admin system tab ----------------------------------------------------------------------- - -function QuotaModal({ - userId, - email, - onClose, -}: { - userId: number; - email: string; - onClose: () => void; -}) { - const { t } = useTranslation(); - const qc = useQueryClient(); - const q = useQuery({ - queryKey: qk.dlQuota(userId), - queryFn: () => api.adminGetDownloadQuota(userId), - }); - const [form, setForm] = useState<{ - gb: number; - jobs: number; - conc: number; - unlimited: boolean; - } | null>(null); - const cur = q.data; - const state = - form ?? - (cur - ? { - gb: Math.round((cur.max_bytes / GiB) * 10) / 10, - jobs: cur.max_jobs, - conc: cur.max_concurrent, - unlimited: cur.unlimited, - } - : null); - const done = () => { - qc.invalidateQueries({ queryKey: qk.dlQuota(userId) }); - qc.invalidateQueries({ queryKey: qk.adminStorage() }); - onClose(); - }; - const save = useMutation({ - mutationFn: () => - api.adminSetDownloadQuota(userId, { - max_bytes: Math.round(state!.gb * GiB), - max_jobs: state!.jobs, - max_concurrent: state!.conc, - unlimited: state!.unlimited, - }), - onSuccess: done, - }); - const reset = useMutation({ - mutationFn: () => api.adminResetDownloadQuota(userId), - onSuccess: done, - }); - if (!state) return null; - const upd = (p: Partial) => setForm({ ...state, ...p }); - return ( - -
-
- - - -
- -
- - -
-
-
- ); -} - -// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the -// footprint list below only shows users who already have files). -function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) { - const { t } = useTranslation(); - const [q, setQ] = useState(""); - const [open, setOpen] = useState(false); - const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); - const list = (users.data ?? []).filter((u) => !u.is_demo); - const filtered = useMemo(() => { - const s = q.trim().toLowerCase(); - const base = s - ? list.filter( - (u) => - u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s), - ) - : list; - return base.slice(0, 8); - }, [q, list]); - return ( -
- { - setQ(e.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - onBlur={() => setTimeout(() => setOpen(false), 150)} - placeholder={t("downloads.admin.quotaPickPlaceholder")} - className={inputCls} - /> - {open && filtered.length > 0 && ( -
- {filtered.map((u) => ( - - ))} -
- )} -
- ); -} - -function AdminSystem() { - const { t } = useTranslation(); - const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); - const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); - const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); - const s = storage.data; - return ( -
- {s && ( -
- {[ - [t("downloads.admin.readyFiles"), String(s.ready_files)], - [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)], - [ - t("downloads.admin.cap"), - s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"), - ], - ].map(([label, val]) => ( -
-
{label}
-
{val}
-
- ))} -
- )} - - {s && ( -
-
-
{t("downloads.admin.perUser")}
- {/* Set a quota for any user, regardless of whether they've downloaded anything. */} - -
-
- {s.per_user.map((u) => ( -
- {u.email} - {formatBytes(u.footprint_bytes)} - -
- ))} - {s.per_user.length === 0 && ( -
{t("downloads.admin.noFootprint")}
- )} -
-
- )} - -
{t("downloads.tabs.system")}
-
- {(jobs.data ?? []).map((j) => ( -
- {jobTitle(j)} - {j.user_email} - - - {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""} - -
- ))} - {jobs.data && jobs.data.length === 0 && ( -
{t("downloads.empty.admin")}
- )} -
- - {quotaFor && ( - setQuotaFor(null)} /> - )} -
- ); -} - -// --- Main ----------------------------------------------------------------------------------- +import { DownloadRow, IconBtn, UsageBar } from "./downloadRow"; +import { MetaEditModal, ErrorModal } from "./downloadModals"; +import { AdminSystem } from "./downloadAdmin"; export default function DownloadCenter() { const me = useMe(); diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx new file mode 100644 index 00000000..c0f4a965 --- /dev/null +++ b/frontend/src/components/downloadAdmin.tsx @@ -0,0 +1,267 @@ +import { useMemo, useState } from "react"; +import { qk } from "../lib/queryKeys"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Settings2 } from "lucide-react"; +import Modal from "./Modal"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import { api } from "../lib/api"; +import { formatBytes } from "../lib/format"; +import { inputCls } from "./ui/form"; +import { StatusPill, jobTitle } from "./downloadRow"; + +// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job +// list. Split out of DownloadCenter (self-contained, admin-only surface). + +const GiB = 1024 ** 3; + +function QuotaModal({ + userId, + email, + onClose, +}: { + userId: number; + email: string; + onClose: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const q = useQuery({ + queryKey: qk.dlQuota(userId), + queryFn: () => api.adminGetDownloadQuota(userId), + }); + const [form, setForm] = useState<{ + gb: number; + jobs: number; + conc: number; + unlimited: boolean; + } | null>(null); + const cur = q.data; + const state = + form ?? + (cur + ? { + gb: Math.round((cur.max_bytes / GiB) * 10) / 10, + jobs: cur.max_jobs, + conc: cur.max_concurrent, + unlimited: cur.unlimited, + } + : null); + const done = () => { + qc.invalidateQueries({ queryKey: qk.dlQuota(userId) }); + qc.invalidateQueries({ queryKey: qk.adminStorage() }); + onClose(); + }; + const save = useMutation({ + mutationFn: () => + api.adminSetDownloadQuota(userId, { + max_bytes: Math.round(state!.gb * GiB), + max_jobs: state!.jobs, + max_concurrent: state!.conc, + unlimited: state!.unlimited, + }), + onSuccess: done, + }); + const reset = useMutation({ + mutationFn: () => api.adminResetDownloadQuota(userId), + onSuccess: done, + }); + if (!state) return null; + const upd = (p: Partial) => setForm({ ...state, ...p }); + return ( + +
+
+ + + +
+ +
+ + +
+
+
+ ); +} + +// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the +// footprint list below only shows users who already have files). +function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) { + const { t } = useTranslation(); + const [q, setQ] = useState(""); + const [open, setOpen] = useState(false); + const users = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers }); + const list = (users.data ?? []).filter((u) => !u.is_demo); + const filtered = useMemo(() => { + const s = q.trim().toLowerCase(); + const base = s + ? list.filter( + (u) => + u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s), + ) + : list; + return base.slice(0, 8); + }, [q, list]); + return ( +
+ { + setQ(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onBlur={() => setTimeout(() => setOpen(false), 150)} + placeholder={t("downloads.admin.quotaPickPlaceholder")} + className={inputCls} + /> + {open && filtered.length > 0 && ( +
+ {filtered.map((u) => ( + + ))} +
+ )} +
+ ); +} + +export function AdminSystem() { + const { t } = useTranslation(); + const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); + const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 }); + const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null); + const s = storage.data; + return ( +
+ {s && ( +
+ {[ + [t("downloads.admin.readyFiles"), String(s.ready_files)], + [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)], + [ + t("downloads.admin.cap"), + s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"), + ], + ].map(([label, val]) => ( +
+
{label}
+
{val}
+
+ ))} +
+ )} + + {s && ( +
+
+
{t("downloads.admin.perUser")}
+ {/* Set a quota for any user, regardless of whether they've downloaded anything. */} + +
+
+ {s.per_user.map((u) => ( +
+ {u.email} + {formatBytes(u.footprint_bytes)} + +
+ ))} + {s.per_user.length === 0 && ( +
{t("downloads.admin.noFootprint")}
+ )} +
+
+ )} + +
{t("downloads.tabs.system")}
+
+ {(jobs.data ?? []).map((j) => ( +
+ {jobTitle(j)} + {j.user_email} + + + {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""} + +
+ ))} + {jobs.data && jobs.data.length === 0 && ( +
{t("downloads.empty.admin")}
+ )} +
+ + {quotaFor && ( + setQuotaFor(null)} /> + )} +
+ ); +} diff --git a/frontend/src/components/downloadModals.tsx b/frontend/src/components/downloadModals.tsx new file mode 100644 index 00000000..785836e6 --- /dev/null +++ b/frontend/src/components/downloadModals.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { qk } from "../lib/queryKeys"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2 } from "lucide-react"; +import Modal from "./Modal"; +import { api, type DownloadJob } from "../lib/api"; +import { inputCls } from "./ui/form"; +import { IconBtn } from "./downloadRow"; + +// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata +// editing and the failure-detail window. + +// Edit a download's display metadata: title, channel (name + link), and extra reference links. +// All are display-only overrides — the on-disk file is never renamed. +export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [name, setName] = useState(job.display_name || job.asset?.title || ""); + const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || ""); + const [channelUrl, setChannelUrl] = useState( + job.display_uploader_url || job.asset?.uploader_url || "", + ); + // Editable extra-link rows; keep one blank row so there's always somewhere to type. + const [links, setLinks] = useState(() => [...(job.extra_links ?? []), ""]); + + const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l))); + const addLink = () => setLinks((ls) => [...ls, ""]); + const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i)); + + const save = useMutation({ + mutationFn: () => + api.updateDownloadMeta(job.id, { + display_name: name.trim(), + display_uploader: channel.trim(), + display_uploader_url: channelUrl.trim(), + extra_links: links.map((l) => l.trim()).filter(Boolean), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.downloads() }); + onClose(); + }, + }); + + return ( + +
+ + + +
+ {t("downloads.edit.extraLinks")} +
+ {links.map((l, i) => ( +
+ setLink(i, e.target.value)} + placeholder="https://…" + className={inputCls} + /> + removeLink(i)} + title={t("downloads.edit.removeLink")} + danger + > + + +
+ ))} +
+ +
+

{t("downloads.edit.hint")}

+
+ +
+
+
+ ); +} + +// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…), +// where a retry can't help — the queue row still keeps its own retry control regardless. +const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"]; + +// A readable "why did this fail" window for a failed job: a category headline + plain explanation, +// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient. +export function ErrorModal({ + job, + onRetry, + onClose, +}: { + job: DownloadJob; + onRetry: () => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const code = job.error_code || "unknown"; + const [showRaw, setShowRaw] = useState(false); + const retryable = RETRYABLE_ERRORS.includes(code); + return ( + +
+
{t(`downloads.errors.fail.cat.${code}.short`)}
+

{t(`downloads.errors.fail.cat.${code}.body`)}

+ {job.error && ( +
+ + {showRaw && ( +
+                {job.error}
+              
+ )} +
+ )} +
+ {retryable && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx new file mode 100644 index 00000000..c9227d3e --- /dev/null +++ b/frontend/src/components/downloadRow.tsx @@ -0,0 +1,309 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { CalendarClock, Copy, Link2, Scissors } from "lucide-react"; +import clsx from "clsx"; +import { qk } from "../lib/queryKeys"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import { api, type DownloadJob } from "../lib/api"; +import { notify } from "../lib/notifications"; +import { copyText } from "../lib/clipboard"; +import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; + +// Download-row presentation + the small shared primitives (status pill, icon button, usage bar), +// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue, +// library, shared, and admin tabs. + +const STATUS_CLS: Record = { + queued: "text-muted", + running: "text-accent", + paused: "text-warn", + done: "text-success", + error: "text-danger", + canceled: "text-muted", +}; + +// Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse). +// "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar. +const DOWNLOAD_PHASES = ["video", "audio", "editing"]; +const PHASE_KEYS = [ + "video", + "audio", + "editing", + "merging", + "audio_extract", + "thumbnail", + "sponsorblock", + "metadata", + "processing", +]; + +export function StatusPill({ job }: { job: DownloadJob }) { + const { t } = useTranslation(); + // While downloading, show the concrete phase (video / audio / merging) so the user isn't + // confused by the bar resetting between the separate video and audio streams. + if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) { + return ( + {t(`downloads.phase.${job.phase}`)} + ); + } + const key = job.expired ? "expired" : job.status; + return ( + + {t(`downloads.status.${key}`)} + + ); +} + +function Thumb({ job }: { job: DownloadJob }) { + // Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so + // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields + // rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for + // the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed). + const remote = job.asset?.thumbnail_url ?? null; + // `job.poster_url` only SIGNALS a poster exists; the actual src must carry ?account= (see + // api.posterImageUrl). A plain can't send the X-Siftlode-Account header, so without it the + // poster resolves to the session-default account and 404s for a per-tab account's download — which, + // now that the poster is the primary image, reads as a blank Library thumbnail. + const poster = job.poster_url ? api.posterImageUrl(job.id) : null; + const primary = poster || remote; + // On load failure (a genuinely missing poster file) fall back to the remote source thumbnail. + const [src, setSrc] = useState(primary); + useEffect(() => setSrc(primary), [primary]); + return ( +
+ {src ? ( + { + if (remote && src !== remote) setSrc(remote); + }} + /> + ) : null} +
+ ); +} + +export function jobTitle(job: DownloadJob): string { + return job.display_name || job.asset?.title || job.source_ref; +} + +// The channel to show under the title: a user override wins over the auto-extracted asset value. +// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. +function jobChannel(job: DownloadJob): { name: string; url: string | null } { + return { + name: job.display_uploader || job.asset?.uploader || job.source_ref, + url: job.display_uploader_url || job.asset?.uploader_url || null, + }; +} + +export function IconBtn({ + onClick, + title, + danger, + children, +}: { + onClick: () => void; + title: string; + danger?: boolean; + children: ReactNode; +}) { + return ( + + ); +} + +// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. +function displayUrl(url: string): string { + try { + const u = new URL(url); + return (u.host + u.pathname + u.search).replace(/\/$/, ""); + } catch { + return url; + } +} + +// "Downloaded from" reference: opens the original page in a new tab, or copies the link. +function SourceRef({ url }: { url: string }) { + const { t } = useTranslation(); + const copy = async () => { + if (await copyText(url)) { + notify({ level: "success", message: t("downloads.source.copied") }); + } else { + notify({ level: "error", message: t("downloads.source.copyFailed") }); + } + }; + return ( + + ); +} + +// Channel under the title — a clickable link when a channel URL is known, plain text otherwise. +function ChannelLine({ job }: { job: DownloadJob }) { + const { name, url } = jobChannel(job); + if (url) { + return ( + + ); + } + return
{name}
; +} + +export function DownloadRow({ + job, + onShowError, + children, +}: { + job: DownloadJob; + onShowError?: (job: DownloadJob) => void; + children?: ReactNode; +}) { + const { t } = useTranslation(); + const running = job.status === "running"; + return ( +
+ +
+
+ {job.job_kind === "edit" && ( + + {t("downloads.clipBadge")} + + )} + {jobTitle(job)} +
+ +
+ + {job.asset?.size_bytes ? ( + · {formatBytes(job.asset.size_bytes)} + ) : null} + {job.asset?.duration_s ? ( + · {formatDuration(job.asset.duration_s)} + ) : null} + {job.status === "done" && job.created_at ? ( + + · {relativeTime(job.created_at)} + + ) : null} + {job.status === "error" ? ( + + ) : null} +
+ {job.source_url && } + {job.extra_links?.map((url) => ( + + ))} + {running && ( +
+ {job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( + // ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %. +
+
+
+ ) : ( + <> +
+
+
+
+ {job.progress}% + {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} + {job.eta_s ? {formatEta(job.eta_s)} : null} +
+ + )} +
+ )} +
+
{children}
+
+ ); +} + +export function UsageBar() { + const { t } = useTranslation(); + // Poll live (like the library list) so the footprint updates the moment the worker finishes an + // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so + // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. + const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: 2000 }); + const u = usage.data; + if (!u) return null; + const pct = + u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); + return ( +
+
+ {t("downloads.usage.title")} + + {formatBytes(u.footprint_bytes)} + {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "} + {u.unlimited + ? t("downloads.usage.unlimited") + : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })} + +
+ {!u.unlimited && ( +
+
90 ? "bg-danger" : "bg-accent")} + style={{ width: `${pct}%` }} + /> +
+ )} +
+ ); +} From 94a608053a60f3c2e203e14d7a3a3705a69e83f6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 21:35:52 +0200 Subject: [PATCH 07/11] feat(a11y): touch-visible card controls + modal focus trap/restore (E4 S6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX-7 — hover-only card actions were `opacity-0 group-hover:opacity-100`: fine on a mouse, but invisible AND still tappable on a touch device (no hover). New `.reveal` primitive (index.css) gates the hide on `@media (hover: hover)`, so a touch device keeps the control visible; its partner `.reveal-idle` is the idle badge a control replaces (watched/in-progress pill under the watched-toggle), faded on hover and dropped on touch so the two don't collide at the same corner. Migrated the true controls — feed card action toolbar (VideoCard), Plex poster card watched-toggle/info/add across all card variants + their swap badges (plexCards), message pop-out (Messages), edit-interval pencil (Scheduler), and saved-view star/rename/delete (SavedViewsWidget). Pure play-scrim overlays (the whole card is the tap target) stay hover-only by design. FE-9 — Modal.tsx had aria-modal but no focus management. Adds: a Tab/Shift+Tab focus trap (topmost layer only, wraps at the edges and pulls escaped focus back); initial focus into the dialog unless an autoFocus child already placed it; focus restore to the trigger on close (captured in a useState initializer during render, before commit runs autoFocus and moves activeElement inside); and aria-labelledby wiring the dialog to its title. Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): FE-9 trap both directions + pull-back + Escape-restore-to-trigger + aria-labelledby verified; UX-7 feed toolbar opacity 0 on desktop / 1 under mobile (hover:none) emulation, Plex reveal buttons opacity 1 + reveal-idle badges display:none on touch. --- frontend/src/components/Messages.tsx | 2 +- frontend/src/components/Modal.tsx | 67 ++++++++++++++++++-- frontend/src/components/SavedViewsWidget.tsx | 6 +- frontend/src/components/Scheduler.tsx | 2 +- frontend/src/components/VideoCard.tsx | 2 +- frontend/src/components/plexCards.tsx | 34 +++++++--- frontend/src/index.css | 32 ++++++++++ 7 files changed, 124 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 0a0b629b..64559ff2 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -189,7 +189,7 @@ function ConversationList({ onClick={() => openChat(c.partner)} title={t("messages.popOut")} aria-label={t("messages.popOut")} - className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition" + className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition" > diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 041230a3..e2a00d3e 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; @@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history"; import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack"; import { useFullscreenElement } from "../lib/fullscreen"; +// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically +// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a +// collapsed disclosure) so the trap never lands focus on something the user can't see. +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; +const focusablesIn = (root: HTMLElement): HTMLElement[] => + [...root.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null); + // Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the // shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor // dismisses just the error and returns to the editor underneath, and a player below defers to it @@ -27,6 +35,13 @@ export default function Modal({ // Browser/mouse Back closes the topmost modal instead of navigating away. useBackToClose(onClose); const fsRoot = useFullscreenElement(); + const panelRef = useRef(null); + const titleId = useId(); + // Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the + // dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading + // it later (in the effect) would capture a now-inside element that unmounts on close, stranding + // focus on . useState initializer runs exactly once, on first render. + const [restoreTo] = useState(() => document.activeElement as HTMLElement | null); // Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a // drag that begins inside the dialog (e.g. selecting text in an input) and is released out on @@ -39,20 +54,55 @@ export default function Modal({ useEffect(() => { const id = pushLayer(); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && isTopmost(id)) { + if (!isTopmost(id)) return; + if (e.key === "Escape") { e.stopPropagation(); onCloseRef.current(); + return; + } + // Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert + // page behind the backdrop. Only the topmost modal traps (a nested one defers to its child). + if (e.key === "Tab") { + const panel = panelRef.current; + if (!panel) return; + const items = focusablesIn(panel); + const first = items[0]; + const last = items[items.length - 1]; + if (!first || !last) { + e.preventDefault(); + return; + } + const active = document.activeElement; + if (!panel.contains(active)) { + // Focus escaped the dialog (or sits on the panel itself) — pull it back to an edge. + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } } }; window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; + // Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move + // focus to the first control so the dialog opens focused, not stranded on the background. + const panel = panelRef.current; + if (panel && !panel.contains(document.activeElement)) { + (focusablesIn(panel)[0] ?? panel).focus(); + } return () => { window.removeEventListener("keydown", onKey); removeLayer(id); document.body.style.overflow = prev; + // Restore focus to the trigger, if it's still in the document and focusable. + if (restoreTo && restoreTo.isConnected) restoreTo.focus(); }; - }, []); + }, [restoreTo]); return createPortal(
-
+
-

{title}

+

+ {title} +

diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 86fc3116..d0c2bc6d 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -198,7 +198,7 @@ function JobRow({ className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition" > · {t("scheduler.everyMin", { count: job.interval_minutes })} - + )} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index b2a1f77b..24bf28a8 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -62,7 +62,7 @@ function Actions({ // rather than silently rendering a half-clipped control outside the card.
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 1e2b7b84..a9a465c9 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -80,7 +80,7 @@ export function PlexPosterCard({ onToggleWatched(card); }} title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")} - className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" + className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > )} - {/* Watched badge when not hovering (the toggle replaces it on hover). */} + {/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card + has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch, + where the toggle is always shown); a watched show keeps the badge as its sole marker. */} {card.status === "watched" && ( - + {t("plex.watched")} )} @@ -203,7 +209,7 @@ export function SeasonCard({ title={ watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched") } - className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" + className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > @@ -216,18 +222,26 @@ export function SeasonCard({ onAdd(); }} title={t("plex.playlist.addSeason")} - className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" + className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > )} {watched && ( - + {t("plex.watched")} )} {se.status === "in_progress" && ( - + {t("plex.inProgress")} )} @@ -406,7 +420,7 @@ export function EpisodeCard({ onToggleWatched(); }} title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")} - className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition" + className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > diff --git a/frontend/src/index.css b/frontend/src/index.css index ce4b33c9..3e9d8e4b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -536,6 +536,38 @@ body { 0 14px 30px -14px rgba(0, 0, 0, 0.5); } +/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be + `opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover), + but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is + gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered + or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the + control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the + "watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and + dropped entirely on touch — where the toggle is always present, so the two would otherwise collide + at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */ +.reveal-idle { + transition: opacity 150ms ease; +} +@media (hover: hover) { + .reveal { + opacity: 0; + transition: opacity 150ms ease; + } + .group:hover .reveal, + .group:focus-within .reveal, + .reveal:focus-visible { + opacity: 1; + } + .group:hover .reveal-idle { + opacity: 0; + } +} +@media (hover: none) { + .reveal-idle { + display: none; + } +} + /* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs which sit over the feed. Strengthen the pools and let a touch more bleed through the glass. From 4fe6f38f1b127de1d461ee2b5c7e4d0171ce1f49 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 00:47:09 +0200 Subject: [PATCH 08/11] refactor(rail): extract shared FilterRail shell + name the MediaSource seam (E4 S7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three filter rails — feed Sidebar, PlexSidebar, PlaylistsRail — each rendered the same SidePanel chrome (title bar, customize `editing` state, edit-toggle, reset-to-layout-default, edit hint) around a PanelGroups body, differing only in identity + filter content. Extracted that shell into components/FilterRail.tsx: each rail now supplies only what differs (title/icon, active-filter count + Clear-all action, its `items`, and optional content above/below the groups — `top` for the feed's scope toggle, `children` for its tag manager). FilterRail owns the `editing` state, so all three drop their duplicated useState + SidePanel wiring. MediaSource facade (lib/mediaSource.ts): a thin `MediaSourceKind = "feed" | "plex"` naming the two browsable, filterable sources (a PanelId minus "playlists", which is a rail but not a source). Already unified in practice — one SortControl, the source-agnostic ActiveFilter[] chips, PanelId layouts, now the shared shell — so this is formalization, not new capability: a single named seam for a future third source or a cross-source feature. The two media-source rails annotate their `kind` with it; PlaylistsRail passes the plain PanelId. Per the recon, the playlist-dialog merge was deliberately NOT forced (it proved forced + low value). UX-3 control-vocab sweep deliberately deferred — the rails already use the canonical primitives. Behaviour-preserving. Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): all three rails render via FilterRail — feed (scope toggle + panel groups + Customize→editing with reset + 56 reorder affordances, exits clean), Plex (filter groups + customize), Playlists (sections + customize); 0 JS console errors. --- frontend/src/components/FilterRail.tsx | 62 +++++++++++++++++++ frontend/src/components/PlaylistsRail.tsx | 21 +++---- frontend/src/components/PlexSidebar.tsx | 25 ++++---- frontend/src/components/Sidebar.tsx | 72 ++++++++++++----------- frontend/src/lib/mediaSource.ts | 10 ++++ 5 files changed, 131 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/FilterRail.tsx create mode 100644 frontend/src/lib/mediaSource.ts diff --git a/frontend/src/components/FilterRail.tsx b/frontend/src/components/FilterRail.tsx new file mode 100644 index 00000000..2bc59b07 --- /dev/null +++ b/frontend/src/components/FilterRail.tsx @@ -0,0 +1,62 @@ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; + +// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel +// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns +// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to +// the layout default, edit hint); each rail supplies only what actually differs — its identity +// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the +// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager). +// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails +// that need neither. +export default function FilterRail({ + kind, + title, + icon, + count, + actions, + collapsed, + onToggleCollapse, + layout, + setLayout, + items, + top, + children, +}: { + kind: PanelId; + title: string; + icon: ReactNode; + count?: number; + actions?: ReactNode; + collapsed: boolean; + onToggleCollapse: () => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + items: PanelItem[]; + top?: ReactNode; + children?: ReactNode; +}) { + const { t } = useTranslation(); + const [editing, setEditing] = useState(false); + return ( + setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout(kind))} + editHint={t("sidebar.editHint")} + actions={actions} + > + {top} + + {children} + + ); +} diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 8fdfdd17..2c0d7ccb 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -8,9 +8,9 @@ import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { LS, readAccountMerged, writeAccount } from "../lib/storage"; -import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; -import SidePanel from "./SidePanel"; -import PanelGroups, { type PanelItem } from "./PanelGroups"; +import { type PanelLayout } from "../lib/panelLayout"; +import FilterRail from "./FilterRail"; +import { type PanelItem } from "./PanelGroups"; import SortControl from "./SortControl"; import { type SortOption } from "../lib/columnSort"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; @@ -58,7 +58,6 @@ export default function PlaylistsRail({ }, [plSort]); const [newName, setNewName] = useState(""); const [syncing, setSyncing] = useState(false); - const [editing, setEditing] = useState(false); const selectedRef = useRef(null); const scrolledRef = useRef(false); @@ -260,17 +259,15 @@ export default function PlaylistsRail({ ]; return ( - } collapsed={collapsed} onToggleCollapse={onToggleCollapse} - editing={editing} - onToggleEditing={() => setEditing((e) => !e)} - onReset={() => setLayout(defaultLayout("playlists"))} - editHint={t("sidebar.editHint")} - > - - + layout={layout} + setLayout={setLayout} + items={items} + /> ); } diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index a1a051b1..97dbf82b 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -5,11 +5,12 @@ import { plexLang } from "../i18n"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, plexFilterCount, type PlexFilters } from "../lib/api"; -import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; +import { type PanelLayout } from "../lib/panelLayout"; +import { type MediaSourceKind } from "../lib/mediaSource"; import { useDebounced } from "../lib/useDebounced"; import { useScrollFade } from "../lib/useScrollFade"; -import SidePanel from "./SidePanel"; -import PanelGroups, { type PanelItem } from "./PanelGroups"; +import FilterRail from "./FilterRail"; +import { type PanelItem } from "./PanelGroups"; import SegmentedToggle from "./SegmentedToggle"; import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; import { usePlexClearFilters } from "./usePlexActiveFilters"; @@ -70,7 +71,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const collFade = useScrollFade(); const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); - const [editing, setEditing] = useState(false); + // This rail's media source (the Plex library) — names the abstraction the feed and Plex rails + // share; drives the panel layout key. `FilterRail` owns the customize `editing` state now. + const source: MediaSourceKind = "plex"; const qc = useQueryClient(); async function createPlaylist() { const title = newPlaylist.trim(); @@ -402,16 +405,16 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl ]; return ( - } count={activeCount} collapsed={collapsed} onToggleCollapse={onToggleCollapse} - editing={editing} - onToggleEditing={() => setEditing((e) => !e)} - onReset={() => setLayout(defaultLayout("plex"))} - editHint={t("sidebar.editHint")} + layout={layout} + setLayout={setLayout} + items={items} actions={ anyActive ? ( )} {/* Media info page (movies/episodes). */} @@ -219,7 +229,7 @@ export function SeasonCard({ } className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > - + )} {/* Add whole season to a playlist, on hover. */} @@ -244,12 +254,12 @@ export function SeasonCard({ {t("plex.watched")} )} + {/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible + on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown + toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue + and must not be a reveal-idle swap-badge (which display:none-s on touch). */} {se.status === "in_progress" && ( - + {t("plex.inProgress")} )} @@ -430,9 +440,7 @@ export function EpisodeCard({ title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition" > - + )} {ep.status === "watched" && ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e9d8e4b..d7769c43 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -558,7 +558,11 @@ body { .reveal:focus-visible { opacity: 1; } - .group:hover .reveal-idle { + /* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard + focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same + corner for a keyboard user. */ + .group:hover .reveal-idle, + .group:focus-within .reveal-idle { opacity: 0; } } diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 4cfe0460..7bb6f2b7 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -32,8 +32,12 @@ export function useHistorySubview( open: (next: T) => void; back: () => void; } { - const [view, setView] = useState(() => { - // Reload of an in-app entry: history.state._sub. Fresh bookmark: decode the ? param. + // Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the + // ? URL param for a fresh bookmark (whose entry carries the param but no _sub), else root. + // ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a + // bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and + // reading history.state alone would wrongly collapse the bookmarked sub-view to root. + const readView = (): T => { const fromState = window.history.state?._sub as T | undefined; if (fromState != null) return fromState; if (url) { @@ -42,13 +46,15 @@ export function useHistorySubview( if (decoded != null) return decoded; } return root; - }); + }; + const readViewRef = useRef(readView); + readViewRef.current = readView; + const [view, setView] = useState(readView); useEffect(() => { - // Back/Forward restores the sub-view from history.state (the browser restores each entry's URL too). - const onPop = () => setView((window.history.state?._sub as T) ?? root); + // Back/Forward restores the sub-view from the landed entry (state OR its restored ? URL). + const onPop = () => setView(readViewRef.current()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { view, From 640a8efeede9c51d191b4354380d12f4c4c7e89d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 03:49:07 +0200 Subject: [PATCH 11/11] =?UTF-8?q?chore(release):=200.70.0=20=E2=80=94=20E4?= =?UTF-8?q?=20Frontend=20Platform=20(bookmarkable=20URLs=20+=20touch=20a11?= =?UTF-8?q?y)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump VERSION to 0.70.0 and add the user-facing release-notes entry for the E4 Frontend Platform epic: full bookmarkable/shareable address-bar URLs (retiring the "Share view" button), touch-visible card action buttons with a shape (not colour-only) watched marker, and the bookmark-page-closes-on-modal-close fix — over a large internal front-end refactor (god-component splits, usePref, useUrlMirror, FilterRail, Modal focus). No DB migration. --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index dc0bfeb5..d9bd9500 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.69.2 \ No newline at end of file +0.70.0 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 76a3d057..6adec696 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,22 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.70.0", + date: "2026-08-20", + summary: + "The address bar now shows a real link for what you're viewing, so you can bookmark or share a channel, a Plex title, a playlist, or a filtered feed — and card buttons work on touch screens.", + features: [ + "You can now bookmark or share the exact view you're on. The address bar reflects the open channel page, a Plex movie/show, a selected playlist, or the feed with its filters applied, so copying the link reproduces that view for you or anyone you send it to. This replaces the old “Share view” button, which is no longer needed. (The browser tab keeps showing the short page name.)", + ], + fixes: [ + "On touch screens the buttons on a video or Plex card — mark watched, save, add to playlist, info — are now always visible and tappable, instead of only appearing on mouse hover. Watched items also show a filled check, not colour alone, so the state is clear without relying on colour.", + "Opening and closing a dialog no longer closes a channel or Plex page you reached from a bookmarked or shared link.", + ], + chores: [ + "A large internal front-end refactor with no behaviour change: several oversized screens (the Plex browser, the video player, the downloads page) were split into focused files; account preferences and the address-bar handling were folded into single reusable hooks; the three filter sidebars now share one shell; and the pop-up dialogs gained proper keyboard focus handling. Covered by the existing tests plus two review passes — a standard review and a multi-agent deep review.", + ], + }, { version: "0.69.2", date: "2026-08-19",