diff --git a/VERSION b/VERSION index 67d5f802..6e79cb10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.72.0 \ No newline at end of file +0.72.1 \ No newline at end of file diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index cfc909c7..28e42875 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -1,8 +1,9 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Filter, X } from "lucide-react"; import type { ActiveFilter } from "../lib/useActiveFilters"; import { decideChipLayout } from "../lib/chipOverflow"; +import { useChipOverflowFit } from "../lib/useChipOverflowFit"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // The applied-filter row above the feed: every narrowing currently in force, each removable on its @@ -131,52 +132,36 @@ export default function ActiveFilterChips({ const chipRefs = useRef>([]); const plusRef = useRef(null); const clearRef = useRef(null); - // How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill. - const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({ - mode: "all", - visible: filters.length, - }); const n = filters.length; // Re-measure whenever the chip set changes (labels or count) — captures both in one dep. const chipsKey = filters.map((f) => f.key + f.label).join("|"); - useLayoutEffect(() => { - const lane = laneRef.current; - const labelEl = labelRef.current; - if (!lane || !labelEl || n === 0) return; - - const measure = () => { - const rightOf = (el: HTMLElement | null | undefined) => - el ? el.offsetLeft + el.offsetWidth : 0; - const chips = chipRefs.current.slice(0, n); - const plusEl = plusRef.current; - // Measure the real rendered gap off the box between the last chip and the "+N" sample, so it - // scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px. - const lastChip = chips[n - 1]; - const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6; - // "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width. - const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0; - - setLayout( - decideChipLayout({ - n, - labelRight: rightOf(labelEl), - chipRights: chips.map((c) => rightOf(c)), - plusW: plusEl?.offsetWidth ?? 0, - clearW, - avail: lane.clientWidth, - gap, - narrowPx: NARROW_PX, - }), - ); - }; - - measure(); - const ro = new ResizeObserver(measure); - ro.observe(lane); - return () => ro.disconnect(); - }, [n, chipsKey]); + // The shared measurement core hands over the chip right edges + gap + pill width + lane width; this + // policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear + // all" button's width, reserved only when it renders — n > 1) before deciding all/some/count. + const layout = useChipOverflowFit<{ mode: "all" | "some" | "count"; visible: number }>({ + rowRef: laneRef, + chipsRef: chipRefs, + pillRef: plusRef, + deps: `${n}|${chipsKey}`, + initial: { mode: "all", visible: n }, + decide: (g) => { + const labelEl = labelRef.current; + const labelRight = labelEl ? labelEl.offsetLeft + labelEl.offsetWidth : 0; + const clearW = n > 1 ? g.gap + (clearRef.current?.offsetWidth ?? 0) : 0; + return decideChipLayout({ + n, + labelRight, + chipRights: g.chipRights, + plusW: g.pillW, + clearW, + avail: g.avail, + gap: g.gap, + narrowPx: NARROW_PX, + }); + }, + }); if (n === 0) return null; diff --git a/frontend/src/components/Carousel.tsx b/frontend/src/components/Carousel.tsx index 31834026..46f4affa 100644 --- a/frontend/src/components/Carousel.tsx +++ b/frontend/src/components/Carousel.tsx @@ -77,8 +77,10 @@ export function Carousel({ const label = ariaLabel ?? (typeof title === "string" ? title : undefined); + // Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover` + // removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap. const arrowBtn = - "absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + + "reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " + "bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " + "opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75"; diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 99ad3c95..3ed2017a 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -1,9 +1,11 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Check, Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { type Tag } from "../lib/api"; import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; import { useScrollFade } from "../lib/useScrollFade"; +import { fitChipsWithPill } from "../lib/chipOverflow"; +import { useChipOverflowFit } from "../lib/useChipOverflowFit"; // The channel tag surface, shared by the channel manager (table + cards) and the channel detail // page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell: @@ -154,62 +156,25 @@ export function ChannelTagsControl({ const attached = userTags.filter((tg) => attachedIds.includes(tg.id)); // Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their - // natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the - // pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always - // right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real - // rem boxes, never a px guess. + // natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem + // gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving + // the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed — + // and it re-measures on width/scale change via the hook's ResizeObserver. const rowRef = useRef(null); - const measureRef = useRef(null); - const [visible, setVisible] = useState(attached.length); + const chipRefs = useRef>([]); + const pillRef = useRef(null); // Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove. const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|"); - useLayoutEffect(() => { - const row = rowRef.current; - const measure = measureRef.current; - if (!row || !measure) return; - const compute = () => { - const nodes = Array.from(measure.children) as HTMLElement[]; - const pill = nodes[nodes.length - 1]; - if (!pill) return; - const pillW = pill.offsetWidth; - const chips = nodes.slice(0, -1); - // Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box - // between the last chip and the sample pill, rather than assuming a px constant. - const lastChip = chips[chips.length - 1]; - const gap = lastChip - ? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth)) - : 0; - const avail = row.clientWidth; - // Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N" - // pill is needed. The reservation loop below reserves the pill's width for every chip except - // the last and breaks at the first overflow, so on its own it never reaches the only "does the - // whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing - // chip narrower than the pill), leaving "+N" beside empty space. - const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0; - if (lastRight <= avail) { - setVisible(chips.length); - return; - } - let count = chips.length; - for (let i = 0; i < chips.length; i++) { - const chip = chips[i]; - if (!chip) break; - const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer - const moreAfter = i < chips.length - 1; - if (right > avail - (moreAfter ? pillW + gap : 0)) { - count = i; - break; - } - } - setVisible(count); - }; - compute(); - if (typeof ResizeObserver === "undefined") return; - const ro = new ResizeObserver(compute); - ro.observe(row); - return () => ro.disconnect(); - }, [tagsKey]); + const visible = useChipOverflowFit({ + rowRef, + chipsRef: chipRefs, + pillRef, + deps: tagsKey, + initial: attached.length, + decide: (g) => + fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }), + }); const hidden = attached.length - visible; @@ -249,16 +214,27 @@ export function ChannelTagsControl({
- {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */} + {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. Each + chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its right edge; + the pill is the sample the hook reserves room for. */}
- {attached.map((tg) => ( - + {attached.map((tg, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + + ))} - +{attached.length} + + +{attached.length} +
diff --git a/frontend/src/components/FeedFiltersProvider.dom.test.ts b/frontend/src/components/FeedFiltersProvider.dom.test.ts index 2d54581c..b55edddc 100644 --- a/frontend/src/components/FeedFiltersProvider.dom.test.ts +++ b/frontend/src/components/FeedFiltersProvider.dom.test.ts @@ -19,14 +19,6 @@ describe("loadAccountFilters — legacy tagMode migration on seed", () => { expect(f.q).toBe("hi"); // other fields preserved through the merge }); - it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => { - localStorage.setItem( - accountKey(LS.defaultViewFilters, ID)!, - JSON.stringify({ tagMode: "and" }), - ); - expect(loadAccountFilters(ID).tagModes.topic).toBe("and"); - }); - it("leaves tagModes at defaults when nothing is stored", () => { expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" }); }); diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 2a0d9ad3..8ef6d8ea 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -51,16 +51,16 @@ const DEFAULT_FILTERS: FeedFilters = { const FILTERS_KEY = LS.filters; -// This account's own persisted filters (per-account keys — never another account's). A chosen -// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When -// the account isn't known yet (first load, before the tab is pinned), start from defaults; the -// post-login effect loads the real ones once the id arrives. +// This account's own persisted filters (per-account keys — never another account's). When the +// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login +// effect loads the real ones once the id arrives. export function loadAccountFilters(id: number | null): FeedFilters { // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you - // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The - // starred default view only SEEDS a fresh account that has never stored filters: it drives the - // very first load, after which that state persists like any other. (Re-apply the default view - // from the sidebar to return to it.) + // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred + // default view seeds a fresh account that has never stored filters, but that is applied ONCE by + // SavedViewsProvider on its views query's first resolve (which then persists here like any edit) — + // not read from a mirror on this synchronous path, which used to race that resolve. So here we only + // ever read the account's own stored filters, else fall back to DEFAULT_FILTERS. const fKey = accountKey(FILTERS_KEY, id); let hasStored = false; try { @@ -68,19 +68,14 @@ export function loadAccountFilters(id: number | null): FeedFilters { } catch { /* localStorage may be unavailable */ } - // Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path, - // matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes - // MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects - // DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it. + // Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the + // saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the + // RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES + // (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it. if (hasStored && fKey) { const raw = readJSON>(fKey, {}); return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) }; } - const dvKey = accountKey(LS.defaultViewFilters, id); - const def = dvKey ? readJSON | null>(dvKey, null) : null; - if (def) { - return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) }; - } return { ...DEFAULT_FILTERS }; } diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 77adbcab..67ca30ad 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -31,7 +31,7 @@ import { import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; -import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; +import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTPlayer } from "../lib/youtube"; @@ -337,7 +337,10 @@ export default function PlayerModal({ if (e.key === "Escape") { // Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an // anchored popover): it takes the Escape and we do nothing — so we don't also step out of - // maximise or close the player underneath it. + // maximise or close the player underneath it. The consumed-event flag is the reliable signal + // (UX-8): the layer's `document` listener runs before ours and its close can pop it off the + // stack synchronously, so `anyLayerAbove` alone would miss it and close the player too. + if (escapeHandled(e)) return; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (fullscreenElement()) return; diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx new file mode 100644 index 00000000..5255e399 --- /dev/null +++ b/frontend/src/components/PlexCollectionView.dom.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +// +// S4 Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states +// behaviour the deep-review flagged as untested. A leaf member queues as itself; a show member is +// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero +// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection / +// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +beforeAll(() => { + // Poster grids / carousels wire a ResizeObserver; jsdom ships none. + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); +vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true })); + +// Spy notify without dropping the module's other exports (the render subtree uses them). +const h = vi.hoisted(() => ({ notify: vi.fn() })); +vi.mock("../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: (...a: unknown[]) => h.notify(...a), +})); + +import PlexCollectionView from "./PlexCollectionView"; +import { api, type PlexCard, type PlexCollectionDetail, type PlexShowDetail } from "../lib/api"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + h.notify.mockClear(); +}); + +const card = (id: string, type: "movie" | "show"): PlexCard => + ({ + id, + type, + title: id, + year: 2000, + duration_seconds: 3600, + thumb: `/api/plex/image/${id}`, + playable: "direct", + status: "new", + position_seconds: 0, + }) as unknown as PlexCard; + +const detail = (items: PlexCard[]): PlexCollectionDetail => + ({ + id: "col1", + title: "My Collection", + thumb: "/api/plex/image/col1", + year_min: null, + year_max: null, + content_rating: null, + child_count: items.length, + total_runtime_s: 0, + items, + can_edit: false, + editable: false, + }) as unknown as PlexCollectionDetail; + +const showDetail = (episodeIds: string[][]): PlexShowDetail => + ({ + seasons: episodeIds.map((eps) => ({ episodes: eps.map((id) => ({ id })) })), + }) as unknown as PlexShowDetail; + +function mount(items: PlexCard[], onPlay = vi.fn()) { + vi.spyOn(api, "plexCollection").mockResolvedValue(detail(items)); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} + onPlay={onPlay} + onOpenCard={() => {}} + onInfoCard={() => {}} + /> + , + ); + return onPlay; +} + +describe("PlexCollectionView — Play-all", () => { + it("queues a leaf member as itself", async () => { + const onPlay = mount([card("movie1", "movie")]); + fireEvent.click(await screen.findByText("plex.collection.playAll")); + await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1)); + expect(onPlay).toHaveBeenCalledWith("movie1", ["movie1"]); + expect(h.notify).not.toHaveBeenCalled(); + }); + + it("expands a show member into its episodes in season→episode order", async () => { + const spyShow = vi + .spyOn(api, "plexShow") + .mockResolvedValue(showDetail([["s1e1", "s1e2"], ["s2e1"]])); + const onPlay = mount([card("show1", "show")]); + fireEvent.click(await screen.findByText("plex.collection.playAll")); + await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1)); + expect(spyShow).toHaveBeenCalledWith("show1"); + expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]); + }); + + it("surfaces the playEmpty notice (not a silent no-op) when a show expands to zero episodes", async () => { + vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([])); // no seasons → empty queue + const onPlay = mount([card("show1", "show")]); + fireEvent.click(await screen.findByText("plex.collection.playAll")); + await waitFor(() => + expect(h.notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }), + ), + ); + expect(onPlay).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 052680d3..fb6e7d6c 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -6,6 +6,8 @@ import { qk } from "../lib/queryKeys"; import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors"; +import { notify } from "../lib/notifications"; +import { showEpisodeKeys } from "../lib/plexQueue"; import { useConfirm } from "./ConfirmProvider"; import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; import { PlexPosterCard } from "./plexCards"; @@ -57,6 +59,9 @@ export default function PlexCollectionView({ const [name, setName] = useState(""); const [adding, setAdding] = useState(false); const [busy, setBusy] = useState(false); + // Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle + // meanwhile so a double-tap can't fire two queues. + const [queueBusy, setQueueBusy] = useState(false); const invalidate = () => { qc.invalidateQueries({ queryKey: qk.plexCollections() }); @@ -64,8 +69,48 @@ export default function PlexCollectionView({ }; const items = d?.items ?? []; - // The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly. - const playable = items.filter((c) => c.type !== "show").map((c) => c.id); + // Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so + // Play-all expands it into its episodes (buildQueue). Either kind means there's something to play — + // this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's + // Play-all was empty and did nothing. + const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id); + const hasShows = items.some((c) => c.type === "show"); + const canPlay = leafKeys.length > 0 || hasShows; + + // Build the ordered play queue: leaf members as themselves, each show member expanded into its + // episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click), + // in parallel, but concatenated in member order so the queue follows the grid — not on page load, + // which would fetch every show up front for a button that may never be pressed. + async function buildQueue(): Promise { + const perMember = await Promise.all( + items.map(async (c) => { + if (c.type !== "show") return [c.id]; + const detail = await qc.fetchQuery({ + queryKey: qk.plexShow(c.id), + queryFn: () => api.plexShow(c.id), + }); + return showEpisodeKeys(detail); + }), + ); + return perMember.flat(); + } + + async function playAll(shuffle: boolean) { + setQueueBusy(true); + try { + let queue = await buildQueue(); + if (shuffle) queue = shuffled(queue); + // canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched / + // empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly. + if (queue.length > 0) onPlay(queue[0]!, queue); + else notify({ level: "info", message: t("plex.collection.playEmpty") }); + } catch (e) { + notifyActionError(e, t("plex.collection.playFailed")); + } finally { + setQueueBusy(false); + } + } + const canEdit = isAdmin && !!d?.can_edit; // A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares // isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them. @@ -171,9 +216,9 @@ export default function PlexCollectionView({ 0} - onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)} + posterDisabled={!canPlay || queueBusy} + showPlayOverlay={canPlay} + onPosterClick={() => canPlay && !queueBusy && playAll(false)} customizeMenu={null} > {renaming ? ( @@ -232,19 +277,18 @@ export default function PlexCollectionView({ )}
- {playable.length > 0 && ( + {canPlay && ( <> onPlay(playable[0]!, playable)} + onClick={() => playAll(false)} + disabled={queueBusy} icon={Play} label={t("plex.collection.playAll")} /> { - const order = shuffled(playable); - onPlay(order[0]!, order); - }} + onClick={() => playAll(true)} + disabled={queueBusy} icon={Shuffle} label={t("plex.collection.shuffle")} /> diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx index 25c0ba51..a689417a 100644 --- a/frontend/src/components/PlexLibraryPicker.tsx +++ b/frontend/src/components/PlexLibraryPicker.tsx @@ -41,13 +41,19 @@ export default function PlexLibraryPicker({ if (debounced.length === 0) return <>{emptyHint}; if (q.isLoading) return ; if (q.isError) return

{t("common.loadError")}

; - if (results.length === 0) return

{t("plex.collection.addNoResults")}

; + if (results.length === 0) + return

{t("plex.collection.addNoResults")}

; return (
    {results.map((c) => (
  • - + {c.title} {c.year != null && · {c.year}} diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 1fe3eef3..76a7d8de 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -53,7 +53,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { useBackToClose } from "../lib/history"; -import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; +import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); @@ -602,7 +602,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { case "Escape": // Defer to any layer opened above the player (Modal or anchored popover) — let it take the // Escape, not us. A player MENU registers as a layer too, so it closes first and the next - // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). + // Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the + // consumed-event flag first (UX-8): a layer's `document` listener runs before this `window` + // one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it. + if (escapeHandled(e)) break; if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break; if (skipProgressRef.current != null) cancelAutoSkipRef.current(); else if (menuOpenRef.current) { diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx new file mode 100644 index 00000000..ad23dae0 --- /dev/null +++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +// +// S2 — the single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider +// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH +// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the +// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a +// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called; +// api.savedViews is stubbed and driven through a real QueryClient. +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, cleanup, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const h = vi.hoisted(() => ({ + page: "feed" as "feed" | "plex", + setFeedFilters: vi.fn(), + setPlexFilters: vi.fn(), +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); +vi.mock("./NavigationProvider", () => ({ useNavigation: () => ({ page: h.page }) })); +vi.mock("./FeedFiltersProvider", () => ({ + useFeedFilters: () => ({ + tags: [], + channelIds: [], + tagModes: { language: "or", topic: "or", other: "or" }, + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", + }), + useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }), +})); +vi.mock("./PlexProvider", () => ({ + usePlex: () => ({ filters: {} }), + usePlexActions: () => ({ setFilters: h.setPlexFilters }), +})); +vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true })); + +import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider"; +import { api, setActiveAccount, type SavedView } from "../lib/api"; +import { LS, accountKey } from "../lib/storage"; +import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; + +const ID = 7; +const view = (over: Partial): SavedView => ({ + id: 1, + name: "v", + filters: {}, + position: 0, + is_default: false, + ...over, +}); + +function Probe() { + const { loading } = useSavedViews(); + return
    {String(loading)}
    ; +} + +async function mountResolved(views: SavedView[]) { + vi.spyOn(api, "savedViews").mockResolvedValue(views); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + // Wait for the query to resolve so the first-resolve effect has run. + await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false")); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID)); + h.page = "feed"; + h.setFeedFilters.mockClear(); + h.setPlexFilters.mockClear(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("SavedViewsProvider — default-view seeding on first resolve", () => { + it("feed: seeds the starred default into a fresh module (no stored filters)", async () => { + await mountResolved([ + view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }), + ]); + expect(h.setFeedFilters).toHaveBeenCalledTimes(1); + expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" }); + }); + + it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => { + // The provider mounts above , which pins the tab-account only after /api/me; the ungated + // views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its + // one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload). + sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); + await mountResolved([ + view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }), + ]); + expect(h.setFeedFilters).not.toHaveBeenCalled(); + // App pins the account; the reactive id re-fires the effect and the deferred seed lands. + act(() => setActiveAccount(ID)); + await waitFor(() => expect(h.setFeedFilters).toHaveBeenCalledTimes(1)); + expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" }); + }); + + it("feed: does NOT seed when the account already has stored filters", async () => { + localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" })); + await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]); + expect(h.setFeedFilters).not.toHaveBeenCalled(); + }); + + it("feed: does NOT seed when no view is starred as default", async () => { + await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]); + expect(h.setFeedFilters).not.toHaveBeenCalled(); + }); + + it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => { + h.page = "plex"; + await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]); + expect(h.setPlexFilters).toHaveBeenCalledTimes(1); + expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] }); + }); + + it("plex: does NOT seed when plex filters are already stored", async () => { + h.page = "plex"; + localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] })); + await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]); + expect(h.setPlexFilters).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index c02db1a1..c17b9dd3 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -1,4 +1,12 @@ -import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; @@ -12,10 +20,11 @@ import { type PlexFilters, type SavedView, } from "../lib/api"; -import { filtersToParams } from "../lib/urlState"; -import { preferSticky } from "../lib/savedViews"; +import { filtersToParams, hasFilterParams } from "../lib/urlState"; +import { pickDefaultToSeed, preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; -import { accountKey, LS, writeJSON } from "../lib/storage"; +import { accountKey, LS } from "../lib/storage"; +import { useActiveAccountId } from "../lib/activeAccount"; import { useConfirm } from "./ConfirmProvider"; import { useNavigation } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -46,20 +55,35 @@ interface ViewConfig { keyOf: (f: Record) => string; asFilters: (raw: Record) => Record; stripForSave: (f: Record) => Record; - /** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */ - syncDefaultMirror?: (views: SavedView[]) => void; + /** Whether this module is a genuinely fresh start the starred default may seed on first load: no + * stored filters for the active account, and — feed only — no URL filter override. */ + canSeedDefault: () => boolean; } const feedKeyOf = (f: Record) => filtersToParams(asFilters(f as unknown as FeedFilters)).toString(); const plexKeyOf = (f: Record) => plexFiltersSignature(coercePlexFilters(f)); -// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load). -function syncFeedDefaultMirror(views: SavedView[]): void { - const key = accountKey(LS.defaultViewFilters, getActiveAccount()); - if (!key) return; - const def = views.find((v) => v.is_default); - writeJSON(key, def ? def.filters : null); +// True once this account has its own persisted filters for `lsKey` — the signal that the last +// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable → +// report "stored" so a default never seeds blind over an indeterminate state. +function storedFiltersPresent(lsKey: string): boolean { + const key = accountKey(lsKey, getActiveAccount()); + if (!key) return true; + try { + return localStorage.getItem(key) != null; + } catch { + return true; + } +} +// Feed also yields to a URL that carries filter params (a shared/bookmarked view). +function feedCanSeedDefault(): boolean { + if (hasFilterParams(new URLSearchParams(window.location.search))) return false; + return !storedFiltersPresent(LS.filters); +} +// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key. +function plexCanSeedDefault(): boolean { + return !storedFiltersPresent(LS.plexFilters); } export interface SavedViewsContext { @@ -105,6 +129,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { keyOf: plexKeyOf, asFilters: (raw) => coercePlexFilters(raw) as unknown as Record, stripForSave: (f) => f, // Plex has no transient field to drop + canSeedDefault: plexCanSeedDefault, }; } return { @@ -119,7 +144,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { const { seed: _seed, ...rest } = f; return rest; }, - syncDefaultMirror: syncFeedDefaultMirror, + canSeedDefault: feedCanSeedDefault, }; }, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]); @@ -140,13 +165,35 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { setModalOpen(false); }, [source]); - // Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the - // mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes - // on every live filter edit, which would fire this synchronous localStorage write far beyond its intent. - const syncMirror = cfg.syncDefaultMirror; + // The ONE default-application path for both modules. On the views query's FIRST resolve for a + // source, seed the starred default view's filters — but only into a genuinely fresh module (no + // stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists + // it, so it happens once and the normal last-view-wins path takes over, exactly as the storage + // semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's + // login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a + // reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a + // 60s background refetch never re-seeds over the filters you're editing. + // + // WAIT FOR THE ACCOUNT PIN. This provider mounts above , which pins the tab-account only after + // /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null. + // canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored") + // with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never + // re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the + // pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real. + const accountId = useActiveAccountId(); + const seededRef = useRef>(new Set()); useEffect(() => { - if (viewsQuery.data) syncMirror?.(viewsQuery.data); - }, [viewsQuery.data, syncMirror]); + const data = viewsQuery.data; + if (!data || accountId == null || seededRef.current.has(source)) return; + seededRef.current.add(source); + const def = pickDefaultToSeed(data, cfg.canSeedDefault()); + if (!def) return; + cfg.setFilters(cfg.asFilters(def.filters)); + setStickyId(def.id); + // cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg + // (which re-memoizes on every filter edit) is deliberately kept out of the deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewsQuery.data, source, accountId]); const currentKey = cfg.keyOf(cfg.filters); const activeView = useMemo( diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index f4b40dab..e502722c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -262,9 +262,12 @@ function Thumb({
    )} - {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */} + {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only: + reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and + `reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a + permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */} {onOpen && ( -
    +
    {inProgress ? ( <>