From 15a36035139dbdf3d76d8f987c17fdd6b9a4b634 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 01:14:50 +0100 Subject: [PATCH 01/13] fix(player): a step onto an unplayable video carries on the way it went Previous onto an embed-blocked or removed video skipped forward again (the auto-advance direction), straight back to the item just left. The player now remembers the direction of the step that reached the item and the videos YouTube refused this session: an error carries on that way, Previous / Next pass over known-dead items without loading them, and go dark when only those are left. A step of your own skips with auto-advance off too. --- .../features/player/PlayerModal.dom.test.tsx | 84 +++++++++++++- frontend/src/features/player/PlayerModal.tsx | 107 ++++++++++++------ .../player/playerModalControls.dom.test.tsx | 2 + .../features/player/playerModalControls.tsx | 9 +- .../lib/playerModalHooks.apiload.dom.test.tsx | 3 +- .../playerModalHooks.progress.dom.test.tsx | 3 +- frontend/src/lib/playerModalHooks.ts | 30 +++-- frontend/src/lib/playerQueue.test.ts | 24 +++- frontend/src/lib/playerQueue.ts | 22 ++++ 9 files changed, 225 insertions(+), 59 deletions(-) diff --git a/frontend/src/features/player/PlayerModal.dom.test.tsx b/frontend/src/features/player/PlayerModal.dom.test.tsx index 2c2bbd90..ef439ef9 100644 --- a/frontend/src/features/player/PlayerModal.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.dom.test.tsx @@ -63,7 +63,11 @@ const started: { videoId: string; start: number | undefined }[] = []; // What the player was asked to switch to in place (a description link, a related video). const loaded: { videoId: string; startSeconds: number }[] = []; // The last player's event handlers, so a test can say "the player is ready" when it wants to. -let playerEvents: { onReady?: () => void; onStateChange?: (e: { data: number }) => void } = {}; +let playerEvents: { + onReady?: () => void; + onStateChange?: (e: { data: number }) => void; + onError?: (e: { data: number }) => void; +} = {}; const base = { getCurrentTime: () => clock.current, getDuration: () => clock.duration, @@ -1816,3 +1820,81 @@ describe("PlayerModal — clip mode", () => { expect(screen.getByTestId("clip-panel")).toBeTruthy(); }); }); + +// An embed-blocked or deleted video answers with onError, never 'ended'. Stepping BACK onto one used +// to skip forward again (the auto-advance direction), straight to the item you had just left. +describe("PlayerModal — stepping over a video that cannot play", () => { + const A = vid("A", 0); + const B = vid("B", 0); + const C = vid("C", 0); + const fail = () => act(() => playerEvents.onError?.({ data: 150 })); + const opened = (id: string) => started.filter((s) => s.videoId === id).length; + + beforeEach(() => { + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({ + video_id: id, + position_seconds: 0, + })); + }); + + const open = async (startIndex: number) => { + renderModal( + {}} + onState={vi.fn()} + />, + ); + await waitFor(() => expect(lastStart()?.videoId).toBe([A, B, C][startIndex]!.id)); + }; + + it("Previous onto a dead video goes on back, not forward to where you came from", async () => { + me = { related_enabled: true, preferences: { playerAutoAdvance: "next" } } as unknown as Me; + await open(2); + prev(); + await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + fail(); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + }); + + it("a step of your own skips a dead video with auto-advance off too", async () => { + await open(2); + prev(); + await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + fail(); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + }); + + it("a video found dead is stepped over without loading it again", async () => { + await open(2); + prev(); + await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + fail(); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + next(); + await waitFor(() => expect(lastStart()?.videoId).toBe("C")); + expect(opened("B")).toBe(1); + }); + + it("Previous is off when only dead videos are before you", async () => { + await open(0); + fail(); // opened on it, auto-advance off: it stays, and it is known dead now + await settle(); + expect(lastStart()?.videoId).toBe("A"); + next(); + await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + for (const b of screen.getAllByRole("button", { name: "player.previous" })) + expect((b as HTMLButtonElement).disabled).toBe(true); + }); + + it("a video opened dead with auto-advance off stays on its error", async () => { + await open(1); + fail(); + await settle(); + expect(lastStart()?.videoId).toBe("B"); + expect(started).toHaveLength(1); + }); +}); diff --git a/frontend/src/features/player/PlayerModal.tsx b/frontend/src/features/player/PlayerModal.tsx index ed6cd06a..d2bcd5ba 100644 --- a/frontend/src/features/player/PlayerModal.tsx +++ b/frontend/src/features/player/PlayerModal.tsx @@ -72,7 +72,7 @@ import { linkedPlayer, playerParamUpdates, type PlayerPanelTab } from "../../lib import ChannelNameLink from "../video/ChannelNameLink"; import Toaster from "../../components/ui/Toaster"; import { useOverlayOrder } from "../../components/ui/overlayOrder"; -import { afterRemoval } from "../../lib/playerQueue"; +import { afterRemoval, stepIndex } from "../../lib/playerQueue"; import { useNativeControlsYield, useYtBigView, @@ -118,6 +118,11 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { // a rem width, so it grows with the text size; the video takes what is left. const WIDE_PLAYER_PX = 1280; const SIDE_PANEL = "20rem"; +// The way a queue step goes: back, on, or to a random other item. +type Way = 1 | -1 | "random"; +const autoWay = (a: Exclude): Way => + a === "random" ? "random" : a === "next" ? 1 : -1; + // The side panel shut to a strip: one icon button wide, close / open / previous / next in a column. const PANEL_STRIP = "2.75rem"; // The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth). @@ -657,15 +662,46 @@ export default function PlayerModal({ // Prev/next stepping through the queue (the feed's order, or a playlist). Read the live // queue+index from a ref so the window keydown handler (bound once) always steps from the // current position, not a stale closure. - const navRef = useLatest({ queue, index }); - const goPrev = () => { - const { queue: q, index: i } = navRef.current; - if (q && i > 0) setPlayingId(q[i - 1]!.id); + // + // The videos YouTube refused to play this session (embed-blocked, removed): a step passes over + // them, and Previous / Next go dark when only those are left that way. State, not a ref, because + // the buttons read it. + const [dead, setDead] = useState>(() => new Set()); + const navRef = useLatest({ queue, index, dead }); + // Which way the step that reached the playing item went — what an item that turns out unplayable + // carries on along. null: no step yet (the item the player opened on). + const stepRef = useRef<{ way: Way } | null>(null); + const stepTo = (id: string, way: Way) => { + stepRef.current = { way }; + setPlayingId(id); }; - const goNext = () => { - const { queue: q, index: i } = navRef.current; - if (q && i < q.length - 1) setPlayingId(q[i + 1]!.id); + // Step from `i` the given way over the videos known dead; whether there was anywhere to go. + const moveOn = ( + q: Video[], + i: number, + way: Way, + d: ReadonlySet, + wrap: boolean, + ): boolean => { + if (way === "random") { + const rest = q.filter((v, k) => k !== i && !d.has(v.id)); + if (!rest.length) return false; + stepTo(rest[Math.floor(Math.random() * rest.length)]!.id, "random"); + return true; + } + const j = stepIndex(q, i, way, d, wrap); + if (j < 0) return false; + stepTo(q[j]!.id, way); + return true; }; + const step = (dir: 1 | -1) => { + const { queue: q, index: i, dead: d } = navRef.current; + if (q) moveOn(q, i, dir, d, false); + }; + const goPrev = () => step(-1); + const goNext = () => step(1); + const canPrev = !!queue && stepIndex(queue, index, -1, dead) >= 0; + const canNext = !!queue && stepIndex(queue, index, 1, dead) >= 0; // "Remove from this playlist", offered right where you are watching the thing you want gone. The // parent owns the removal (API + caches + the toast whose Undo puts it back); the player owns what happens NEXT, because it // is the only side that knows which item is playing — its queue is frozen at mount, so the parent @@ -699,7 +735,7 @@ export default function PlayerModal({ return; } setQueue(next.queue); - setPlayingId(next.playingId); + stepTo(next.playingId, 1); // the item that followed: a dead one there carries on forward }; // Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s. @@ -738,28 +774,28 @@ export default function PlayerModal({ if (l === "one") return replayCurrent(); // A related video's end is not the queue's: the list beside it is where the next one is picked. if (related) return; - const { queue: q, index: i } = navRef.current; - if (!q || q.length === 0) return; + const { queue: q, index: i, dead: d } = navRef.current; + if (!q || q.length === 0 || a === "off") return; const wrap = l === "all"; - if (a === "next") { - if (i < q.length - 1) setPlayingId(q[i + 1]!.id); - else if (wrap) { - if (q.length === 1) replayCurrent(); - else setPlayingId(q[0]!.id); - } - } else if (a === "prev") { - if (i > 0) setPlayingId(q[i - 1]!.id); - else if (wrap) { - if (q.length === 1) replayCurrent(); - else setPlayingId(q[q.length - 1]!.id); - } - } else if (a === "random" && q.length > 1) { - let r = i; - while (r === i) r = Math.floor(Math.random() * q.length); - setPlayingId(q[r]!.id); - } else if (a === "random" && wrap) { - replayCurrent(); - } + // Nowhere else to go (a one-item list, or every other item dead): Loop "All" repeats this one. + if (!moveOn(q, i, autoWay(a), d, wrap) && wrap) replayCurrent(); + }; + // The playing queue item turned out unplayable (YouTube's onError — it never reaches 'ended'). + // Remember it, then carry on the way the step that reached it went: Previous onto a dead video + // goes on back, not forward to the one you just left. The item the player opened on was reached + // by no step, so it moves on only with auto-advance on, in its direction. `go` false: the run of + // errors hit the player's cap — remembered, but playback stays on the error. + const skipUnplayable = (go: boolean) => { + if (related || clipOpenRef.current) return; + const { queue: q, index: i, dead: d } = navRef.current; + const gone = q?.[i]?.id; + if (!q || !gone) return; + const now = new Set(d).add(gone); + setDead(now); + if (!go || q.length <= 1) return; + const { autoMode: a, loopMode: l } = modeRef.current; + const way = stepRef.current?.way ?? (a === "off" ? null : autoWay(a)); + if (way != null) moveOn(q, i, way, now, l === "all"); }; // Lazy description (fetched only when it is opened). It opens from an ⓘ beside the title, by click @@ -982,14 +1018,13 @@ export default function PlayerModal({ mountRef, playerRef, currentIdRef, - navRef, - modeRef, setWatched, setPlayerError, setLiveData, reapplyStored, reclaimFocus, advanceOnEnd, + skipUnplayable, sessionPosRef, qc, }); @@ -1177,6 +1212,8 @@ export default function PlayerModal({ variant="docked" index={index} queueLength={queue!.length} + canPrev={canPrev} + canNext={canNext} autoMode={autoMode} loopMode={loopMode} onPrev={goPrev} @@ -1332,7 +1369,7 @@ export default function PlayerModal({ @@ -1341,7 +1378,7 @@ export default function PlayerModal({ = queue!.length - 1} + disabled={!canNext} label={t("player.next")} title={`${t("player.next")} · Shift+→`} > @@ -1377,6 +1414,8 @@ export default function PlayerModal({ variant="modal" index={index} queueLength={queue!.length} + canPrev={canPrev} + canNext={canNext} autoMode={autoMode} loopMode={loopMode} onPrev={goPrev} diff --git a/frontend/src/features/player/playerModalControls.dom.test.tsx b/frontend/src/features/player/playerModalControls.dom.test.tsx index 41cecc0f..f72b89da 100644 --- a/frontend/src/features/player/playerModalControls.dom.test.tsx +++ b/frontend/src/features/player/playerModalControls.dom.test.tsx @@ -24,6 +24,8 @@ function mount(variant: "modal" | "docked", autoMode: AutoMode, loopMode: LoopMo variant={variant} index={1} queueLength={3} + canPrev + canNext autoMode={autoMode} loopMode={loopMode} onPrev={() => {}} diff --git a/frontend/src/features/player/playerModalControls.tsx b/frontend/src/features/player/playerModalControls.tsx index e9e62378..9d5c62f9 100644 --- a/frontend/src/features/player/playerModalControls.tsx +++ b/frontend/src/features/player/playerModalControls.tsx @@ -27,6 +27,8 @@ export function PlayerTransportControls({ variant, index, queueLength, + canPrev, + canNext, autoMode, loopMode, onPrev, @@ -37,6 +39,9 @@ export function PlayerTransportControls({ variant: "docked" | "modal"; index: number; queueLength: number; + /** Whether there is a playable item that way (one YouTube refused is passed over). */ + canPrev: boolean; + canNext: boolean; autoMode: AutoMode; loopMode: LoopMode; onPrev: () => void; @@ -70,7 +75,7 @@ export function PlayerTransportControls({ name: t("player.previous"), title: `${t("player.previous")} · Shift+←`, onClick: onPrev, - disabled: index === 0, + disabled: !canPrev, labelFrom: "sm", }, { @@ -79,7 +84,7 @@ export function PlayerTransportControls({ name: t("player.next"), title: `${t("player.next")} · Shift+→`, onClick: onNext, - disabled: index === queueLength - 1, + disabled: !canNext, labelFrom: "sm", trailing: true, }, diff --git a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx index 41acf1af..36ee92cc 100644 --- a/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx +++ b/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx @@ -34,14 +34,13 @@ function mountPlayer(setPlayerError: (code: number | null) => void) { mountRef: { current: mount }, playerRef: createRef() as RefObject, currentIdRef: { current: VID }, - navRef: { current: { queue: undefined, index: 0 } }, - modeRef: { current: { autoMode: "off", loopMode: "off" } }, setWatched: () => {}, setPlayerError, setLiveData: () => {}, reapplyStored: () => {}, reclaimFocus: () => {}, advanceOnEnd: () => {}, + skipUnplayable: () => {}, sessionPosRef: { current: new Map() }, qc: new QueryClient(), }), diff --git a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx index 0f9aaa25..17331d38 100644 --- a/frontend/src/lib/playerModalHooks.progress.dom.test.tsx +++ b/frontend/src/lib/playerModalHooks.progress.dom.test.tsx @@ -76,14 +76,13 @@ function mountPlayer(qc: QueryClient, sessionPos: Map) { mountRef: { current: mount }, playerRef: createRef() as RefObject, currentIdRef: { current: VID }, - navRef: { current: { queue: undefined, index: 0 } }, - modeRef: { current: { autoMode: "off", loopMode: "off" } }, setWatched: () => {}, setPlayerError: () => {}, setLiveData: () => {}, reapplyStored: () => {}, reclaimFocus: () => {}, advanceOnEnd: () => {}, + skipUnplayable: () => {}, sessionPosRef: { current: sessionPos }, qc, }), diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index dfe5de55..e956bc83 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -17,12 +17,11 @@ import { requestFullscreen, } from "./fullscreen"; import { useBackToExit } from "./history"; -import { api, type Video } from "./api"; +import { api } from "./api"; import { qk } from "./queryKeys"; import { patchCachedProgress, trackProgressFlush } from "./progressCache"; import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube"; import { logYouTubeApiFailed, logYouTubeError, logYouTubeState } from "./mediaLog"; -import type { AutoMode, LoopMode } from "./playerModes"; // How close to the end (seconds) counts as "finished" → auto-mark watched. const FINISH_MARGIN = 10; @@ -334,7 +333,7 @@ function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null { /** 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 — + * event closures read what must stay live via the refs passed in. Mirrors Plex's usePlexProgress — * side-effect only, returns nothing. */ export function useYtProgress({ activeId, @@ -343,14 +342,13 @@ export function useYtProgress({ mountRef, playerRef, currentIdRef, - navRef, - modeRef, setWatched, setPlayerError, setLiveData, reapplyStored, reclaimFocus, advanceOnEnd, + skipUnplayable, sessionPosRef, qc, }: { @@ -361,14 +359,14 @@ export function useYtProgress({ mountRef: RefObject; playerRef: RefObject; currentIdRef: RefObject; - navRef: RefObject<{ queue: Video[] | undefined; index: number }>; - modeRef: RefObject<{ autoMode: AutoMode; loopMode: LoopMode }>; setWatched: (on: boolean) => void; setPlayerError: (code: number | null) => void; setLiveData: (d: { title?: string; author?: string } | null) => void; reapplyStored: () => void; reclaimFocus: () => void; advanceOnEnd: () => void; + /** The item turned out unplayable; `go` false once a run of them hit the cap (stay on the error). */ + skipUnplayable: (go: boolean) => void; // Positions this player session has actually STORED, per video id. Written from the server's // answer, read back by the modal's resume so stepping next→prev inside one session does not // rewind to the value the frozen launch queue was snapshotted with. @@ -388,6 +386,7 @@ export function useYtProgress({ reapplyStored, reclaimFocus, advanceOnEnd, + skipUnplayable, qc, })); @@ -403,6 +402,7 @@ export function useYtProgress({ reapplyStored, reclaimFocus, advanceOnEnd, + skipUnplayable, qc, } = itemSnapshot(); // A position this session already stored for the item beats the queue's snapshot of it. @@ -492,16 +492,12 @@ export function useYtProgress({ onError: (e: YTPlayerEvent) => { logYouTubeError(typeof e?.data === "number" ? e.data : -1); 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. + // onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and the + // queue would freeze on it — the modal skips it the way the step that reached it went, + // 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(); + skipUnplayable(errorStreakRef.current < MAX_CONSECUTIVE_ERRORS); }, }, }); @@ -565,9 +561,9 @@ export function useYtProgress({ // would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets // this cleanup's late auto-mark land on the item being LEFT, which the modal records for that // item and never paints on the next one. What genuinely has to stay live mid-session is behind - // refs (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) + // refs (currentIdRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) // — and THAT split is the enforcement: a value that is not a snapshot does not belong in this // closure. (This replaced an exhaustive-deps suppression, which made the React Compiler skip // the whole hook.) - }, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]); + }, [activeId, mountRef, playerRef, currentIdRef, sessionPosRef]); } diff --git a/frontend/src/lib/playerQueue.test.ts b/frontend/src/lib/playerQueue.test.ts index 3add3a90..fdb19e8a 100644 --- a/frontend/src/lib/playerQueue.test.ts +++ b/frontend/src/lib/playerQueue.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { afterRemoval } from "./playerQueue"; +import { afterRemoval, stepIndex } from "./playerQueue"; import type { Video } from "./api"; // "Delete" from inside the player (the owner asked for it: today the only way to drop a video from a @@ -51,3 +51,25 @@ describe("afterRemoval", () => { expect(next!.playingId).toBe("a"); }); }); + +describe("stepIndex", () => { + const q = [v("a"), v("b"), v("c"), v("d")]; + const none = new Set(); + it("steps to the neighbour that way", () => { + expect(stepIndex(q, 1, 1, none)).toBe(2); + expect(stepIndex(q, 1, -1, none)).toBe(0); + }); + it("passes over a video known dead", () => { + expect(stepIndex(q, 3, -1, new Set(["c", "b"]))).toBe(0); + expect(stepIndex(q, 0, 1, new Set(["b"]))).toBe(2); + }); + it("is -1 at the end, or when only dead videos are left that way", () => { + expect(stepIndex(q, 3, 1, none)).toBe(-1); + expect(stepIndex(q, 2, -1, new Set(["a", "b"]))).toBe(-1); + }); + it("wraps when asked, and never lands on where it started", () => { + expect(stepIndex(q, 3, 1, none, true)).toBe(0); + expect(stepIndex(q, 0, -1, new Set(["d"]), true)).toBe(2); + expect(stepIndex(q, 1, 1, new Set(["a", "c", "d"]), true)).toBe(-1); + }); +}); diff --git a/frontend/src/lib/playerQueue.ts b/frontend/src/lib/playerQueue.ts index 2eac82ce..17c23f0e 100644 --- a/frontend/src/lib/playerQueue.ts +++ b/frontend/src/lib/playerQueue.ts @@ -28,3 +28,25 @@ export function afterRemoval( const next = rest[Math.min(Math.max(index, 0), rest.length - 1)]!; return { queue: rest, playingId: next.id }; } + +/** The nearest item from `index` going `dir` that is not known to be unplayable, or -1 when there is + * none that way. `wrap` goes round the ends (Loop "All"), never back onto `index` itself. + * + * A video YouTube refused to play (embed-blocked, removed) is remembered for the player session, so + * Previous / Next pass over it instead of loading it again only to be bounced off it. */ +export function stepIndex( + queue: readonly Video[], + index: number, + dir: 1 | -1, + dead: ReadonlySet, + wrap = false, +): number { + const n = queue.length; + for (let k = 1; k < n; k++) { + const raw = index + dir * k; + if (!wrap && (raw < 0 || raw >= n)) return -1; + const j = ((raw % n) + n) % n; + if (!dead.has(queue[j]!.id)) return j; + } + return -1; +} From e06c0688d9ddd6f82a110792402eba5b00d50353 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 01:20:20 +0100 Subject: [PATCH 02/13] feat(video): Reset view on every YouTube video menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row that sets a video back to fresh (its state row and resume point both go) was only on the feed's cards. It is now on Home's shelves, a Playlists row's menu and the player's related list too, and is named "Reset view" / "Megtekintés visszaállítása". --- .../player/RelatedVideos.dom.test.tsx | 9 +++++++ .../src/features/player/RelatedVideos.tsx | 19 +++++++++++++-- .../video/useVideoActions.dom.test.tsx | 3 ++- .../src/features/video/useVideoActions.tsx | 6 ++--- frontend/src/i18n/locales/en/card.json | 2 +- frontend/src/i18n/locales/hu/card.json | 2 +- .../src/modules/feed/Feed.reset.dom.test.tsx | 2 +- frontend/src/modules/home/Home.dom.test.tsx | 21 ++++++++++++++++ frontend/src/modules/home/Home.tsx | 16 +++++++++++-- .../modules/playlists/Playlists.dom.test.tsx | 23 ++++++++++++++++++ frontend/src/modules/playlists/Playlists.tsx | 24 ++++++++++++++++++- 11 files changed, 115 insertions(+), 12 deletions(-) diff --git a/frontend/src/features/player/RelatedVideos.dom.test.tsx b/frontend/src/features/player/RelatedVideos.dom.test.tsx index 482562de..62f3c044 100644 --- a/frontend/src/features/player/RelatedVideos.dom.test.tsx +++ b/frontend/src/features/player/RelatedVideos.dom.test.tsx @@ -163,6 +163,15 @@ describe("RelatedVideos", () => { await waitFor(() => expect(titles()).toEqual(["Video A"])); }); + it("resets a started video's view from its menu", async () => { + vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })])); + const reset = vi.spyOn(api, "clearState").mockResolvedValue(undefined as never); + setup(); + fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!); + fireEvent.click(menuItem("card.resetState")); + await waitFor(() => expect(reset).toHaveBeenCalledWith("A")); + }); + it("reads further with the cursor the last page gave", async () => { const asked = vi .spyOn(api, "related") diff --git a/frontend/src/features/player/RelatedVideos.tsx b/frontend/src/features/player/RelatedVideos.tsx index e412abb2..8fe79bd2 100644 --- a/frontend/src/features/player/RelatedVideos.tsx +++ b/frontend/src/features/player/RelatedVideos.tsx @@ -25,7 +25,7 @@ import Tooltip from "../../components/ui/Tooltip"; import VideoCard from "../video/VideoCard"; import { VideoMenu } from "../video/VideoMenu"; import { announceVideoState } from "../video/stateNotice"; -import { writeVideoState, writeWatchLater } from "../video/videoWrites"; +import { resetVideoState, writeVideoState, writeWatchLater } from "../video/videoWrites"; import { msg } from "../../lib/notifications"; /** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood, @@ -106,6 +106,21 @@ export function RelatedVideos({ }); }; + const onResetState = (id: string) => { + const seq = begin(id, { status: "new", position_seconds: 0 }); + const write = resetVideoState(qc, id); + onWrite?.(write); + write + .then(() => { + settle(seq); + void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); + }) + .catch((e: unknown) => { + notifyActionError(e, msg("errors.writeFailed.videoReset")); + settle(seq); + }); + }; + const onToggleSave = (id: string, saved: boolean) => { const seq = begin(id, { saved }); const write = writeWatchLater(qc, id, saved); @@ -231,7 +246,7 @@ export function RelatedVideos({ )} )} - + ); } diff --git a/frontend/src/features/video/useVideoActions.dom.test.tsx b/frontend/src/features/video/useVideoActions.dom.test.tsx index d32bf903..1f72e517 100644 --- a/frontend/src/features/video/useVideoActions.dom.test.tsx +++ b/frontend/src/features/video/useVideoActions.dom.test.tsx @@ -39,7 +39,7 @@ function downloadRow(state: string | undefined) { {children} ); const { result } = renderHook( - () => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn() }, true), + () => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() }, true), { wrapper }, ); return () => result.current.items(video, null).find((i) => i.id === "download")!; @@ -74,6 +74,7 @@ describe("the video menu's channel-filter row", () => { { onState: vi.fn(), onToggleSave: vi.fn(), + onResetState: vi.fn(), channelFilter: ids ? { ids, set } : undefined, }, true, diff --git a/frontend/src/features/video/useVideoActions.tsx b/frontend/src/features/video/useVideoActions.tsx index 1abfeba2..bd5e3b08 100644 --- a/frontend/src/features/video/useVideoActions.tsx +++ b/frontend/src/features/video/useVideoActions.tsx @@ -38,8 +38,8 @@ import { raw } from "../../lib/notifications"; export interface VideoActionHandlers { onState: (id: string, status: VideoStatus) => void; onToggleSave: (id: string, saved: boolean) => void; - /** Omitted where a surface cannot reset a video's state (Home's shelves). */ - onResetState?: (id: string) => void; + /** Back to fresh: its state row and resume point both go. */ + onResetState: (id: string) => void; /** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */ onOpen?: (v: Video, startAt?: number | null) => void; /** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel @@ -189,7 +189,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { separatorBefore: true, onSelect: () => onState(v.id, hidden ? "new" : "hidden"), }, - ...(onResetState && isResettable(v) + ...(isResettable(v) ? [ { id: "reset", diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json index 2513b3c9..e8186c28 100644 --- a/frontend/src/i18n/locales/en/card.json +++ b/frontend/src/i18n/locales/en/card.json @@ -8,7 +8,7 @@ "saveForLater": "Save for later", "unhide": "Unhide", "hide": "Hide", - "resetState": "Reset — clear watch progress/status", + "resetState": "Reset view", "thisChannel": "This channel", "continueTitle": "Continue where you left off", "continue": "Continue", diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json index f8bd0835..ec6f0092 100644 --- a/frontend/src/i18n/locales/hu/card.json +++ b/frontend/src/i18n/locales/hu/card.json @@ -8,7 +8,7 @@ "saveForLater": "Mentés későbbre", "unhide": "Megjelenítés", "hide": "Elrejtés", - "resetState": "Alaphelyzet — nézési előzmény/státusz törlése", + "resetState": "Megtekintés visszaállítása", "thisChannel": "Ez a csatorna", "continueTitle": "Folytatás ott, ahol abbahagytad", "continue": "Folytatás", diff --git a/frontend/src/modules/feed/Feed.reset.dom.test.tsx b/frontend/src/modules/feed/Feed.reset.dom.test.tsx index ad8f21d1..d75e1bd6 100644 --- a/frontend/src/modules/feed/Feed.reset.dom.test.tsx +++ b/frontend/src/modules/feed/Feed.reset.dom.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only +// "Reset view" on a half-watched video. The optimistic layer overrode only // the STATUS, so the card kept its resume position — its progress bar and its Continue button — until // the next refetch (found in a UAT). The grid is replaced by a list that shows what Feed // hands it: each video's position, and a button that fires the reset. diff --git a/frontend/src/modules/home/Home.dom.test.tsx b/frontend/src/modules/home/Home.dom.test.tsx index cfb8c0e9..ef674864 100644 --- a/frontend/src/modules/home/Home.dom.test.tsx +++ b/frontend/src/modules/home/Home.dom.test.tsx @@ -46,12 +46,14 @@ vi.mock("react-i18next", async (importOriginal) => { const discover = vi.fn(); const discoverPick = vi.fn(); const videoRow = vi.fn(); +const clearState = vi.fn((_id: string) => Promise.resolve()); vi.mock("../../lib/api", () => ({ api: { discover: (...a: unknown[]) => discover(...a), videoRow: (...a: unknown[]) => videoRow(...a), discoverPick: (...a: unknown[]) => discoverPick(...a), setState: () => Promise.resolve(), + clearState: (id: string) => clearState(id), watchLaterAdd: () => Promise.resolve(), watchLaterRemove: () => Promise.resolve(), downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it @@ -803,6 +805,25 @@ describe("Home", () => { expect(setPage).toHaveBeenCalledWith("plex"); }); + it("a started video's menu resets its view, and Home asks again", async () => { + discover.mockResolvedValue({ + budget: "any", + hero: null, + continue_watching: [ytCard("v1", "Half seen")], + comfort: [], + }); + mount(); + fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!); + const reset = screen + .getAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "card.resetState"); + expect(reset).toBeTruthy(); + const asked = discover.mock.calls.length; + fireEvent.click(reset!); + await waitFor(() => expect(clearState).toHaveBeenCalledWith("v1")); + await waitFor(() => expect(discover.mock.calls.length).toBeGreaterThan(asked)); + }); + // C5: what a tile's Play would continue, its menu can start over — the one way to on touch. it("a started film's menu starts it over: the player opens from the start", async () => { const movie = { diff --git a/frontend/src/modules/home/Home.tsx b/frontend/src/modules/home/Home.tsx index 67203451..b1652c42 100644 --- a/frontend/src/modules/home/Home.tsx +++ b/frontend/src/modules/home/Home.tsx @@ -52,7 +52,11 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard"; import { useVideoActions } from "../../features/video/useVideoActions"; -import { writeVideoState, writeWatchLater } from "../../features/video/videoWrites"; +import { + resetVideoState, + writeVideoState, + writeWatchLater, +} from "../../features/video/videoWrites"; import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions"; import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards"; import { usePlaylistsActions } from "../playlists"; @@ -156,7 +160,15 @@ function useHomeCardMenu({ const pin = useComfortPin(menu.open); // A video tile's own action list, the same one the feed's menu shows; the rail keeps its // Play row (it resolves `next`) and its shelf rows around it. - const videoActions = useVideoActions({ onState, onToggleSave }, menu.open); + const qc = useQueryClient(); + // Back to fresh: what the rails answer changes with it (a reset video leaves Continue watching), + // so Home asks again, as its marks do. + const onResetState = (id: string) => { + void resetVideoState(qc, id) + .then(() => qc.invalidateQueries({ queryKey: qk.discover() })) + .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoReset"))); + }; + const videoActions = useVideoActions({ onState, onToggleSave, onResetState }, menu.open); // A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does). const plexActions = usePlexCardActions( { diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index d4c3136c..ed9d4732 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -569,6 +569,29 @@ async function rowMenu(title: string, item: string) { ); } +describe("Playlists — a row's watch state", () => { + it("resets a started video's view from its menu", async () => { + vi.spyOn(api, "clearState").mockResolvedValue(undefined as never); + mount({ 1: [vid("A", 0), vid("B", 300)] }); + await rowMenu("Video B", "card.resetState"); + await waitFor(() => expect(api.clearState).toHaveBeenCalledWith("B")); + }); + + it("offers no reset on a video never started", async () => { + mount({ 1: [vid("A", 0)] }); + const row = (await screen.findByRole("button", { name: "card.play — Video A" })).closest("tr")!; + fireEvent.contextMenu(row); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); + expect( + screen + .getAllByRole("menuitem", { hidden: true }) + .some((b) => b.textContent === "card.resetState"), + ).toBe(false); + }); +}); + describe("Playlists — a change the server refused is taken back on screen", () => { // The resync refetch returns the SAME list the server had, react-query keeps the same `data` // object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index 9e92bd80..fb4f4c9f 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -3,10 +3,12 @@ import { useLatest, useStableCallback } from "../../lib/useLatest"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; +import { isResettable } from "../../lib/api/feed"; import { addPlaylistItem, movePlaylistItem, removePlaylistItem, + resetVideoState, writeVideoState, } from "../../features/video/videoWrites"; import { @@ -25,7 +27,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Check, FolderInput, History, ListPlus, Play, X } from "lucide-react"; +import { Check, Eraser, FolderInput, History, ListPlus, Play, X } from "lucide-react"; import { YouTubeIcon } from "../../lib/brandIcons"; import { api, @@ -683,6 +685,16 @@ function PlaylistDetail() { return write; } + // Back to fresh (the video's state row and resume point both go), from the row's menu. + function resetRow(id: string) { + resetVideoState(qc, id) + .then(() => { + qc.invalidateQueries({ queryKey: qk.playlist() }); + qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); + }) + .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoReset"))); + } + // Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure // every render re-subscribed those listeners and re-ran focusModal() under the open player. const closePlayer = () => setPlaying(null); @@ -732,6 +744,16 @@ function PlaylistDetail() { onSelect: () => openPlayer(ctxRow.id), }, ...comfortPin.item(pinVideo(ctxRow.id), raw(ctxRow.title ?? "")), + ...(isResettable(ctxRow) + ? [ + { + id: "reset", + label: t("card.resetState"), + icon: , + onSelect: () => resetRow(ctxRow.id), + }, + ] + : []), ...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({ ...it, separatorBefore: i === 0, From 85fd9b6704c7ec84793024bec123d73396a12bc7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 01:30:30 +0100 Subject: [PATCH 03/13] feat(channels): an Added column, and About cards that wait for a resting pointer The channel manager lists when each follow reached the list (the subscription import that first saw it, or a subscribe made here) as a sortable Added date after Last upload. The About cell's card opens after the pointer rests on it for half a second instead of at once, and a scroll puts an open one away. --- backend/app/routes/channels.py | 4 ++ backend/tests/test_catalog_routes.py | 20 ++++++ frontend/src/i18n/locales/en/channels.json | 1 + frontend/src/i18n/locales/hu/channels.json | 1 + frontend/src/lib/api/channels.ts | 3 + .../modules/channels/AboutCell.dom.test.tsx | 69 +++++++++++++++++++ frontend/src/modules/channels/AboutCell.tsx | 10 ++- .../channels/channelColumns.dom.test.tsx | 39 +++++++++++ .../src/modules/channels/channelColumns.tsx | 16 ++++- .../channels/useChannelActions.dom.test.tsx | 1 + 10 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 frontend/src/modules/channels/AboutCell.dom.test.tsx diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 284ea366..beedfa9e 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -76,6 +76,10 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_ "details_synced": ch.details_synced_at is not None, "recent_synced": ch.recent_synced_at is not None, "backfill_done": ch.backfill_done, + # When the follow reached this list: written by the subscription import the run that + # first saw it, or by a subscribe made here — not the channel row's own date, which an + # explored or discovered channel had before it was followed. + "added_at": iso(sub.created_at), } for sub, ch in rows ] diff --git a/backend/tests/test_catalog_routes.py b/backend/tests/test_catalog_routes.py index e144433a..01be059a 100644 --- a/backend/tests/test_catalog_routes.py +++ b/backend/tests/test_catalog_routes.py @@ -72,3 +72,23 @@ def test_the_sync_status_counts_the_callers_subscriptions(db, make_user, as_user assert (body["subscriptions"], body["channels_total"]) == (1, 1) mine = as_user(user).get("/api/sync/my-status").json() assert (mine["channels_total"], mine["channels_recent_pending"]) == (1, 1) + + +def test_a_channel_lists_when_it_entered_this_users_list_not_when_the_channel_did( + db, make_user, as_user +): + from datetime import datetime, timezone + + user = make_user() + db.add(Channel(id="UCa", title="A", created_at=datetime(2026, 6, 10, tzinfo=timezone.utc))) + db.flush() + db.add( + Subscription( + user_id=user.id, + channel_id="UCa", + created_at=datetime(2026, 10, 12, 8, 30, tzinfo=timezone.utc), + ) + ) + db.commit() + listed = as_user(user).get("/api/channels").json() + assert [(c["id"], c["added_at"]) for c in listed] == [("UCa", "2026-10-12T08:30:00+00:00")] diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 4cc834fa..2b45a4a6 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -82,6 +82,7 @@ "stored": "Stored", "subs": "Subscribers", "lastUpload": "Last upload", + "added": "Added", "length": "Length", "tags": "Tags", "actions": "Actions" diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 5ff9c159..a800caf5 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -82,6 +82,7 @@ "stored": "Tárolt", "subs": "Feliratkozók", "lastUpload": "Utolsó feltöltés", + "added": "Hozzáadva", "length": "Hossz", "tags": "Címkék", "actions": "Műveletek" diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts index c3bd4b3f..7ca3c755 100644 --- a/frontend/src/lib/api/channels.ts +++ b/frontend/src/lib/api/channels.ts @@ -73,6 +73,9 @@ export interface ManagedChannel { description: string | null; stored_videos: number; last_video_at: string | null; + /** When the follow reached this list: the subscription import that first saw it, or a + * subscribe made here. */ + added_at: string; total_duration_seconds: number; priority: number; hidden: boolean; diff --git a/frontend/src/modules/channels/AboutCell.dom.test.tsx b/frontend/src/modules/channels/AboutCell.dom.test.tsx new file mode 100644 index 00000000..c1702427 --- /dev/null +++ b/frontend/src/modules/channels/AboutCell.dom.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +// +// The About cell's card opened the instant a pointer crossed the cell, so scrolling the table over +// a column of them popped card after card. It waits for the pointer to rest on the cell now, and a +// scroll puts an open one away. +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, act, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import AboutCell from "./AboutCell"; +import { api } from "../../lib/api"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(api, "channelDetail").mockReturnValue(new Promise(() => {})); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return screen.getByText("An element of truth"); +} +// Present in the DOM is enough: it paints once floating-ui has placed it, which a fake clock never lets happen. +const card = () => document.querySelector('[role="tooltip"]'); +const wait = (ms: number) => act(() => vi.advanceTimersByTime(ms)); +const enter = (el: HTMLElement) => + act(() => { + fireEvent.pointerEnter(el); + fireEvent.mouseEnter(el); + fireEvent.mouseMove(el); + }); + +describe("AboutCell — the hover card", () => { + it("does not open for a pointer passing over the cell", async () => { + const cell = mount(); + enter(cell); + await wait(300); + expect(card()).toBeNull(); + fireEvent.mouseLeave(cell); + await wait(600); + expect(card()).toBeNull(); + }); + + it("opens once the pointer rests on it", async () => { + const cell = mount(); + enter(cell); + await wait(550); + expect(card()).not.toBeNull(); + }); + + it("goes away when the table scrolls", async () => { + const cell = mount(); + enter(cell); + await wait(550); + expect(card()).not.toBeNull(); + act(() => void window.dispatchEvent(new Event("scroll"))); + await wait(50); + expect(card()).toBeNull(); + }); +}); diff --git a/frontend/src/modules/channels/AboutCell.tsx b/frontend/src/modules/channels/AboutCell.tsx index 955bdc57..989997e5 100644 --- a/frontend/src/modules/channels/AboutCell.tsx +++ b/frontend/src/modules/channels/AboutCell.tsx @@ -19,6 +19,9 @@ import { SURFACE } from "../../components/ui/surfaces"; * The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel * properly keyboard-reachable belongs to the focus/a11y work, not to the mechanical migration. */ +// How long the pointer rests on a cell before its card opens. +const ABOUT_REST_MS = 500; + export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) { const preview = (text ?? "").trim(); const [open, setOpen] = useState(false); @@ -28,8 +31,11 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch onOpenChange: setOpen, mode: "hover", offset: 6, - // Pop instantly like the old cell did; the close grace lets the cursor reach the card. - hoverDelay: { open: 0, close: 160 }, + // Opens once the pointer rests on the cell, not as it crosses one: scrolling the table under a + // still pointer, or moving down the column, used to pop card after card. The close grace lets + // the cursor reach the card; a scroll puts an open one away. + hoverDelay: { open: ABOUT_REST_MS, close: 160 }, + dismissOnScroll: true, // Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a // long About never runs its top off-screen — at high browser zoom the short viewport made a fixed // max-height overflow above the top edge, where the scroll container can't reach. diff --git a/frontend/src/modules/channels/channelColumns.dom.test.tsx b/frontend/src/modules/channels/channelColumns.dom.test.tsx index a6ebbd23..977f10b5 100644 --- a/frontend/src/modules/channels/channelColumns.dom.test.tsx +++ b/frontend/src/modules/channels/channelColumns.dom.test.tsx @@ -13,6 +13,8 @@ import { } from "./channelColumns"; import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api"; import type { TFunction } from "i18next"; +import { render, screen } from "@testing-library/react"; +import { formatDate } from "../../lib/format"; const ch = (over: Partial): ManagedChannel => ({ id: "c", @@ -24,6 +26,7 @@ const ch = (over: Partial): ManagedChannel => ({ description: null, stored_videos: 0, last_video_at: null, + added_at: "2026-06-10T08:00:00+00:00", total_duration_seconds: 0, priority: 0, hidden: false, @@ -147,3 +150,39 @@ describe("buildChannelColumns — the row's end", () => { expect(cols[cols.length - 1]!.render(ch({ id: "UCz" }))).toBe("more:UCz"); }); }); + +describe("buildChannelColumns — Added", () => { + const noop = () => {}; + const cols = buildChannelColumns({ + t: ((k: string) => k) as never, + lang: "en", + prioOptions: [], + userTags: [], + bumpPriority: noop, + setPriority: noop, + more: () => null, + onView: noop, + toggleTag: noop, + createAndAttachTag: noop, + onFilterByTag: noop, + suggestionsFor: () => [], + onAcceptSuggestion: noop, + onDismissSuggestion: noop, + }); + const added = cols.find((c) => c.key === "added"); + + it("follows Last upload, and sorts by when the follow reached the list", () => { + expect(added?.header).toBe("channels.cols.added"); + const keys = cols.map((c) => c.key); + expect(keys.indexOf("added")).toBe(keys.indexOf("lastUpload") + 1); + expect(added?.sortable).toBe(true); + const early = ch({ added_at: "2026-06-10T08:00:00+00:00" }); + const late = ch({ added_at: "2026-10-12T08:00:00+00:00" }); + expect(added!.sortValue!(late) as number).toBeGreaterThan(added!.sortValue!(early) as number); + }); + + it("shows the day, not a relative time", () => { + render(<>{added!.render(ch({ added_at: "2026-10-12T08:00:00+00:00" }))}); + expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx index 499f3ee3..1c8899c0 100644 --- a/frontend/src/modules/channels/channelColumns.tsx +++ b/frontend/src/modules/channels/channelColumns.tsx @@ -2,7 +2,7 @@ import type { ComponentProps, ReactNode } from "react"; import type { TFunction } from "i18next"; import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../../lib/api"; import { tagPickLabel, type TagList } from "../../lib/tagPicks"; -import { formatCountOrDash, formatTotalHours } from "../../lib/format"; +import { formatCountOrDash, formatDate, formatTotalHours } from "../../lib/format"; import type { Column } from "../../components/ui/DataTable"; import { PriorityStepper } from "../../components/ui/PriorityStepper"; import ChannelLink from "./ChannelLink"; @@ -204,6 +204,20 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column (c.last_video_at ? new Date(c.last_video_at).getTime() : 0), render: (c) => , }, + { + key: "added", + header: t("channels.cols.added"), + nowrap: true, + // The day, not a relative time: beside Last upload two "… ago" columns read as one, and when + // a channel was added is a fixed fact. Room for "2026. okt. 12." and a sort arrow. + width: "7.5rem", + tabletHidden: true, + sortable: true, + sortValue: (c) => new Date(c.added_at).getTime(), + render: (c) => ( + {formatDate(c.added_at, lang)} + ), + }, { key: "length", header: t("channels.cols.length"), diff --git a/frontend/src/modules/channels/useChannelActions.dom.test.tsx b/frontend/src/modules/channels/useChannelActions.dom.test.tsx index 108ccfa8..66d0bd32 100644 --- a/frontend/src/modules/channels/useChannelActions.dom.test.tsx +++ b/frontend/src/modules/channels/useChannelActions.dom.test.tsx @@ -59,6 +59,7 @@ const ch = (over: Partial = {}): ManagedChannel => ({ description: null, stored_videos: 0, last_video_at: null, + added_at: "2026-06-10T08:00:00+00:00", total_duration_seconds: 0, priority: 0, hidden: false, From 41052eb4eac1f4371cfbd14ae2c660be303e0904 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 03:30:49 +0100 Subject: [PATCH 04/13] feat(files): a press anywhere on a file's row plays it A table row (and its narrow card) can carry its object's default action: a click or tap on the row runs it, a press on a control inside the row, the click that ends a text selection and a drag do not. --- frontend/src/components/ui/DataTable.tsx | 18 ++++-- frontend/src/components/ui/README.md | 2 +- frontend/src/components/ui/rowOpen.ts | 60 +++++++++++++++++++ .../src/modules/files/Files.menu.dom.test.tsx | 58 ++++++++++++++++++ frontend/src/modules/files/Files.tsx | 2 + 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/ui/rowOpen.ts diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index bf179689..463243b4 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -9,6 +9,7 @@ import { alignClass } from "./tableAlign"; import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns"; import { navItemAttrs, useListNav } from "../../lib/listNav"; import { useMediaQuery } from "../../lib/useMediaQuery"; +import { withRowOpen } from "./rowOpen"; import { SURFACE } from "./surfaces"; import { HiddenFilterCue } from "./HiddenFilterCue"; import { useProgressiveRows } from "./progressiveRows"; @@ -57,6 +58,7 @@ interface TableRowProps { index: number; columns: Column[]; rowProps?: (row: T) => Record; + rowOpen?: (row: T) => (() => void) | null; rowClassName?: (row: T) => string; keyboardNav: boolean; } @@ -69,14 +71,16 @@ function TableRow({ index, columns, rowProps, + rowOpen, rowClassName, keyboardNav, }: TableRowProps) { + const open = rowOpen?.(row) ?? null; return ( {columns.map((col) => ( ({ emptyText, rowClassName, rowProps, + rowOpen, controlsPosition = "bottom", controlsLeading, controlsInBand = false, @@ -137,6 +142,10 @@ export default function DataTable({ // answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free // and never learns what a menu is. rowProps?: (row: T) => Record; + // The row's default action (play, open), or null where a row has none: a press anywhere on the row + // or its card twin runs it, except on a control inside it or at the end of a text selection + // (`rowOpen.ts`). The keyboard's Enter keeps reaching it through the row's `data-nav-open` element. + rowOpen?: (row: T) => (() => void) | null; controlsPosition?: "top" | "bottom" | "both"; controlsLeading?: ReactNode; // Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead @@ -414,6 +423,7 @@ export default function DataTable({ index={i} columns={columns} rowProps={rowProps} + rowOpen={rowOpen} rowClassName={rowClassName} keyboardNav={keyboardNav} /> @@ -430,9 +440,9 @@ export default function DataTable({ {shown.map((row, i) => (
{columns .filter((c) => c.cardPrimary) diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 8dd13ac4..adfdacc0 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -39,7 +39,7 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) | | actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw ` - )} - + {!navigated && } + {!navigated && ( + + )} + {/* Clip mode here: the clip panel takes this bar's place. */} + {playerError == null && ( + + + + )} + {!navigated && ( + setWatched(!watched)} + label={watched ? t("player.watchedUnmark") : t("player.markWatched")} + > + {watched ? : } + + )} +
+ {/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there the window-filling view is the biggest, and YouTube's own button opens the system player. */} @@ -1269,9 +1274,8 @@ export default function PlayerModal({ {isFullscreen ? : }
)} - + +
)} {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */} diff --git a/frontend/src/features/player/playerModalControls.dom.test.tsx b/frontend/src/features/player/playerModalControls.dom.test.tsx index f72b89da..dce07efd 100644 --- a/frontend/src/features/player/playerModalControls.dom.test.tsx +++ b/frontend/src/features/player/playerModalControls.dom.test.tsx @@ -60,6 +60,20 @@ describe("PlayerTransportControls", () => { expect(loop.hasAttribute("data-lit")).toBe(false); }); + // The big view's bar spoke its controls' names from a width up, and on an iPad the row outgrew the + // screen: its last control scrolled out of sight. A glyph in both places; the name is the label. + for (const variant of ["modal", "docked"] as const) + it(`${variant}: every control is its glyph alone`, () => { + mount(variant, "next", "all"); + for (const name of [ + "player.previous", + "player.next", + /^player\.autoAdvance\.label: /, + /^player\.loop\.label: /, + ]) + expect(button(name).textContent).toBe(""); + }); + for (const variant of ["modal", "docked"] as const) it(`${variant}: each mode's tooltip names it and the mode it is in`, () => { for (const loopMode of LOOP_MODES) { diff --git a/frontend/src/features/player/playerModalControls.tsx b/frontend/src/features/player/playerModalControls.tsx index 9d5c62f9..408dd602 100644 --- a/frontend/src/features/player/playerModalControls.tsx +++ b/frontend/src/features/player/playerModalControls.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; import type { VideoDetail } from "../../lib/api"; import type { AutoMode, LoopMode } from "../../lib/playerModes"; -import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; import { StateMessage } from "../../components/ui/QueryState"; import { ClampedText } from "../../components/ui/ClampedText"; @@ -15,11 +14,11 @@ import { renderDescription } from "./descriptionLinks"; /** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes. * Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal - * glass theme, glyphs only so the whole cluster fits one line of the side panel) and the big-view - * docked control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). What a - * control does, its glyph and its tooltip are said once; only the primitive and the label's - * visibility differ. Every control's tooltip carries its full name (and the shortcut, where it has - * one), so a glyph-only one still says what it does and which mode it is in. The modes are cycles, + * glass theme) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only + * in both: the docked bar once spoke the names from a width up, and on an iPad its row outgrew the + * screen. What a control does, its glyph and its tooltip are said once; only the look differs. + * Every control's tooltip carries its full name (and the shortcut, where it has one), so a glyph + * still says what it does and which mode it is in. The modes are cycles, * not switches: `lit` when not off, and the name says which. Auto-advance wears list glyphs of its * own — the skip arrows are Previous / Next, beside it (DESIGN.md "A glyph means one thing"). The * wrapper (fragment vs. bordered row) stays at the call site. */ @@ -51,7 +50,8 @@ export function PlayerTransportControls({ }) { const { t } = useTranslation(); const docked = variant === "docked"; - const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; + // Never broken or squeezed: in a row that scrolls, a squeezed "1 / 55" stood on three lines. + const counter = `shrink-0 whitespace-nowrap tabular-nums ${docked ? "text-white/70" : "text-muted"}`; const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`; const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`; @@ -63,10 +63,6 @@ export function PlayerTransportControls({ onClick: () => void; disabled?: boolean; lit?: boolean; - /** From which width the docked bar shows the name beside the glyph. */ - labelFrom: "sm" | "md"; - /** The glyph after the name (Next points the way it goes). */ - trailing?: boolean; }; const step: Control[] = [ { @@ -76,7 +72,6 @@ export function PlayerTransportControls({ title: `${t("player.previous")} · Shift+←`, onClick: onPrev, disabled: !canPrev, - labelFrom: "sm", }, { key: "next", @@ -85,8 +80,6 @@ export function PlayerTransportControls({ title: `${t("player.next")} · Shift+→`, onClick: onNext, disabled: !canNext, - labelFrom: "sm", - trailing: true, }, ]; // Persistent playback settings — cycle on click, saved to your account. @@ -99,7 +92,6 @@ export function PlayerTransportControls({ title: `${autoName} · ${t("player.autoAdvance.hint")}`, onClick: onCycleAuto, lit: autoMode !== "off", - labelFrom: "md", }, { key: "loop", @@ -108,39 +100,21 @@ export function PlayerTransportControls({ title: `${loopName} · ${t("player.loop.hint")}`, onClick: onCycleLoop, lit: loopMode !== "off", - labelFrom: "md", }, ]; - const render = (c: Control) => - docked ? ( - - ) : ( - - {c.glyph} - - ); + const render = (c: Control) => ( + + {c.glyph} + + ); return ( <> {render(step[0]!)} diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json index 76783093..e2169a3f 100644 --- a/frontend/src/i18n/locales/en/player.json +++ b/frontend/src/i18n/locales/en/player.json @@ -45,7 +45,6 @@ "channel": "Channel", "views": "{{formattedCount}} views", "stream": "stream", - "watched": "Watched", "markWatched": "Mark watched", "watchedUnmark": "Watched — click to unmark", "jumpToTime": "Jump to this time", diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index c0afd43a..ddc5cf69 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -45,7 +45,6 @@ "channel": "Csatorna", "views": "{{formattedCount}} megtekintés", "stream": "élő adás", - "watched": "Megtekintve", "markWatched": "Megtekintettnek jelöl", "watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához", "jumpToTime": "Ugrás erre az időpontra", From a29afd916d30a55b51da3da73b13597a3f16192e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 04:18:01 +0100 Subject: [PATCH 07/13] feat(feed): toolbar pills named by a glyph, Sort and View on the filters' row The watch-state, content-type and sort pills on the feed, a channel page and Plex Browse show a glyph where their group's word was; the word moves to the tooltip and stays in the accessible name. Sort + View end the toolbar's row instead of a row of their own, the chip row shows only with a chip, and a channel page drops the count pill its header already carries. Also logs the docked bar's glyph-only decision in DESIGN.md. --- docs/DESIGN.md | 2 + frontend/src/components/ui/FilterSelect.tsx | 13 +- frontend/src/components/ui/SelectTrigger.tsx | 10 ++ frontend/src/components/ui/SortControl.tsx | 10 +- .../modules/feed/Feed.toolbar.dom.test.tsx | 126 ++++++++++++++++++ frontend/src/modules/feed/Feed.tsx | 70 +++++----- .../plex/PlexBrowse.toolbar.dom.test.tsx | 78 +++++++++++ frontend/src/modules/plex/PlexBrowse.tsx | 27 ++-- 8 files changed, 282 insertions(+), 54 deletions(-) create mode 100644 frontend/src/modules/feed/Feed.toolbar.dom.test.tsx create mode 100644 frontend/src/modules/plex/PlexBrowse.toolbar.dom.test.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 01c946fa..5519d723 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -829,3 +829,5 @@ of a full-width panel and nothing on the right. | 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which | | 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one | | 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age | +| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels | +| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos | diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx index d94b4997..822d4e52 100644 --- a/frontend/src/components/ui/FilterSelect.tsx +++ b/frontend/src/components/ui/FilterSelect.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { MenuCheck } from "./MenuCheck"; -import { SelectTrigger } from "./SelectTrigger"; +import { GroupGlyph, SelectTrigger } from "./SelectTrigger"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { SURFACE } from "./surfaces"; @@ -27,6 +27,10 @@ export interface FilterSelectProps { /** Show only the value on the trigger — for a form row whose own caption already names the field. * The label still names the control and its menu for assistive tech. */ hideLabel?: boolean; + /** A glyph that names the group in place of its word, for a toolbar where the value says enough + * once the group is recognisable (the feed's watch state: an eye). The word moves to the tooltip + * and stays in the accessible name. */ + icon?: ReactNode; options: readonly FilterSelectOption[]; selected: string[]; onChange: (next: string[]) => void; @@ -46,6 +50,7 @@ export default function FilterSelect({ multiple = false, requireOne = true, hideLabel = false, + icon, testId, }: FilterSelectProps) { const [open, setOpen] = useState(false); @@ -95,7 +100,9 @@ export default function FilterSelect({ {icon}} + title={icon ? label : undefined} ref={setReference} {...pop.getReferenceProps()} data-testid={testId} diff --git a/frontend/src/components/ui/SelectTrigger.tsx b/frontend/src/components/ui/SelectTrigger.tsx index bef9e098..1e8b769c 100644 --- a/frontend/src/components/ui/SelectTrigger.tsx +++ b/frontend/src/components/ui/SelectTrigger.tsx @@ -12,6 +12,16 @@ import { FocusSafeButton } from "./FocusSafeButton"; // the pills' faint edge, its label naming it (owner decision F14). Both hover by the accent edge. Open is // the accent edge and the chevron turned over. The popover wiring // (ref, `getReferenceProps`, the accessible name carrying the value) is the caller's. +/** A toolbar trigger's group glyph in place of its "Label:" word (FilterSelect's and SortControl's + * `icon`): muted like the word, and silent — the trigger's accessible name and tooltip say it. */ +export function GroupGlyph({ children }: { children: ReactNode }) { + return ( + + ); +} + export const SelectTrigger = forwardRef< HTMLButtonElement, ButtonHTMLAttributes & { diff --git a/frontend/src/components/ui/SortControl.tsx b/frontend/src/components/ui/SortControl.tsx index 7a842533..c3ba37d2 100644 --- a/frontend/src/components/ui/SortControl.tsx +++ b/frontend/src/components/ui/SortControl.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from "react"; import { ArrowDown, ArrowUp } from "lucide-react"; import { MenuCheck } from "./MenuCheck"; -import { SelectTrigger } from "./SelectTrigger"; +import { GroupGlyph, SelectTrigger } from "./SelectTrigger"; import { pickSort, type SortOption, type SortState } from "../../lib/columnSort"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { useAnchoredPopover } from "./anchoredPopoverHooks"; @@ -27,6 +27,7 @@ export default function SortControl({ descLabel, defaultLabel, trailing, + icon, }: { options: SortOption[]; sort: SortState; @@ -40,6 +41,9 @@ export default function SortControl({ defaultLabel?: string; /** Rendered in the panel footer beside the direction segment (feed reshuffle, playlists dirty-first). */ trailing?: ReactNode; + /** A glyph in place of the visible "Sort:" prefix (FilterSelect's `icon`): the label moves to the + * tooltip and stays in the accessible name. */ + icon?: ReactNode; }) { const [open, setOpen] = useState(false); const pop = useAnchoredPopover({ @@ -71,7 +75,9 @@ export default function SortControl({ {icon}} + title={icon ? label : undefined} ref={setReference} {...pop.getReferenceProps()} // Carry the active sort key AND direction in the accessible name (the visible arrow is diff --git a/frontend/src/modules/feed/Feed.toolbar.dom.test.tsx b/frontend/src/modules/feed/Feed.toolbar.dom.test.tsx new file mode 100644 index 00000000..2841ada9 --- /dev/null +++ b/frontend/src/modules/feed/Feed.toolbar.dom.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +// +// The feed's toolbar, on the feed and on a channel page. The filter pills name their group by a glyph +// (the word moved to the tooltip and the accessible name), Sort + View end the first row instead of +// taking a row of their own, and a channel page drops the count pill: its header already says how +// many videos the channel has. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("../../features/wizard/wizardContext", () => ({ + useWizard: () => ({ openWizard: vi.fn() }), +})); +vi.mock("./VirtualFeed", () => ({ default: () => null })); +vi.mock("../../features/filters/SelectedViewChip", () => ({ default: () => null })); +vi.mock("../../features/filters/ActiveFilterChips", () => ({ default: () => null })); + +import Feed from "./Feed"; +import { api, DEFAULT_TAG_MODES, type FeedFilters } from "../../lib/api"; +import { qk } from "../../lib/queryKeys"; +import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const FILTERS: FeedFilters = { + tags: [], + tagModes: DEFAULT_TAG_MODES, + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +const slot = document.body.appendChild(document.createElement("div")); + +async function renderFeed(channelScoped = false) { + vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never); + vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 12, facets: {} } as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); + render( + + + {}} + view="cards" + setView={() => {}} + canRead + ytSearch="" + onYtSearch={() => {}} + onExitYtSearch={() => {}} + channelScoped={channelScoped} + /> + + , + ); + // The toolbar renders once the first page has answered. + await screen.findByTestId("feed-show"); +} + +const trigger = (name: string) => + screen.getAllByRole("button").find((b) => b.getAttribute("aria-label")?.startsWith(`${name}:`))!; +const sortTrigger = () => trigger("feed.sortLabel"); + +describe("Feed toolbar", () => { + it.each([ + ["feed-show", "feed.showLabel"], + ["feed-content", "feed.contentLabel"], + ])("the %s pill shows a glyph, not its group's word, and names itself", async (id, word) => { + await renderFeed(); + const pill = screen.getByTestId(id); + expect(pill.textContent).not.toContain(word); + expect(pill.querySelector("svg")).not.toBeNull(); + expect(pill.getAttribute("title")).toBe(word); + expect(pill.getAttribute("aria-label")).toMatch(new RegExp(`^${word}: `)); + }); + + it("the sort pill shows a glyph, not the word Sort, and names itself", async () => { + await renderFeed(); + const sort = sortTrigger(); + expect(sort.textContent).not.toContain("feed.sortLabel"); + expect(sort.getAttribute("title")).toBe("feed.sortLabel"); + }); + + it.each([false, true])( + "Sort and View end the filters' row instead of a row of their own (channel page: %s)", + async (channelScoped) => { + await renderFeed(channelScoped); + const row = screen.getByTestId("feed-show").parentElement!; + expect(row.contains(sortTrigger())).toBe(true); + expect(row.contains(trigger("feed.viewLabel"))).toBe(true); + }, + ); + + it("a channel page shows no count pill; the feed keeps it", async () => { + await renderFeed(true); + expect(screen.queryByTestId("item-count")).toBeNull(); + cleanup(); + await renderFeed(false); + expect(screen.getByTestId("item-count")).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index 82a282c9..1072a9b2 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -9,8 +9,10 @@ import { import { qk } from "../../lib/queryKeys"; import { nextPageOnClick } from "../../lib/nextPage"; import { + ArrowDownUp, ArrowLeft, Columns2, + Eye, Grid3x3, Info, LayoutGrid, @@ -18,6 +20,7 @@ import { List, RefreshCw, Rows3, + Shapes, Trash2, type LucideIcon, } from "lucide-react"; @@ -749,9 +752,8 @@ export default function Feed({ (k) => t("feed.sortKey." + k), ); - // Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the - // applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the - // same controls render on both the feed and channel-scoped rows. + // Sort + view mode end the toolbar's row, on the feed and on a channel page alike: a row of their + // own under the filters left most of a wide window empty. // One setter per toolbar control, shared with the page's background menu (the same choices, the // same effect, two doors). const setSort = (next: SortState) => { @@ -792,6 +794,7 @@ export default function Feed({ sort={{ key: sortKey, dir: sortDir }} onChange={setSort} label={t("feed.sortLabel")} + icon={} ascLabel={t("feed.dirAsc")} descLabel={t("feed.dirDesc")} trailing={ @@ -814,16 +817,21 @@ export default function Feed({ ); + // One row: the count, the filters, then Sort + View at its end — they wrap to a line of their own + // only when the window cannot hold them beside the filters. const toolbar = (
{/* The item count is the fixed leading element — a fixed-width pill so a changing number - never shoves the controls beside it (see ItemCount). */} - + never shoves the controls beside it (see ItemCount). A channel page has none: its header + already says how many videos the channel has. */} + {!channelScoped && ( + + )} {/* Watch state is single-select (always exactly one); content types are an - independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */} + independent multi-select. Both compress to one dropdown pill via FilterSelect, named + by a glyph: the value says enough once the group is recognisable, and the word is + in the tooltip and the accessible name. */} } testId="feed-show" options={showOptions} selected={[filters.show]} @@ -842,6 +853,7 @@ export default function Feed({ /> } testId="feed-content" multiple options={contentOptions} @@ -863,10 +875,7 @@ export default function Feed({ )} )} - {/* Folded, the band is one row: Sort + View join it on the right. */} - {fold.folded && ( -
{sortView}
- )} +
{sortView}
); @@ -944,26 +953,19 @@ export default function Feed({
{toolbar} - {/* The controls row sits directly above the grid: the applied-filter chips flow from the left - (overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay - pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you - applied"), but still get the right-pinned Sort/View. */} - {/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the - page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */} - {!fold.folded && ( -
- {!channelScoped && ( - <> - {/* The active personal view (if any) leads the applied-filter row. */} - - setFilters(clearedFilters(filters))} - className="flex-1" - /> - - )} -
{sortView}
+ {/* The applied-filter chips, directly above the grid (overflowing to a "+N"/count pill so + they never crowd a line). A row only while there is a chip: both render nothing when + nothing is applied, and `empty:hidden` takes the empty row's padding away with them. + Channel-scoped feeds show no chips (their baseline isn't a "filter you applied"). */} + {!fold.folded && !channelScoped && ( +
+ {/* The active personal view (if any) leads the applied-filter row. */} + + setFilters(clearedFilters(filters))} + className="flex-1" + />
)}
diff --git a/frontend/src/modules/plex/PlexBrowse.toolbar.dom.test.tsx b/frontend/src/modules/plex/PlexBrowse.toolbar.dom.test.tsx new file mode 100644 index 00000000..e5ea6b16 --- /dev/null +++ b/frontend/src/modules/plex/PlexBrowse.toolbar.dom.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +// +// The Browse grid's toolbar, the feed's in Plex: the watch-state pill and Sort name their group by a +// glyph (the word in the tooltip and the accessible name), and Sort ends the filters' row instead of +// taking a row of its own. +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./plexSavedViews", async (importOriginal) => ({ + ...(await importOriginal()), + usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }), +})); + +import PlexBrowse from "./PlexBrowse"; +import { api } from "../../lib/api"; +import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" }, _subOf: "v" }, "")); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const slot = document.body.appendChild(document.createElement("div")); + +async function mount() { + vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + return screen.findByTestId("plex-show"); +} + +const sortTrigger = () => + screen + .getAllByRole("button") + .find((b) => b.getAttribute("aria-label")?.startsWith("plex.filter.sort:"))!; + +describe("PlexBrowse toolbar", () => { + it("the watch-state pill shows a glyph, not its group's word, and names itself", async () => { + const pill = await mount(); + // The key of a value ("plex.filter.showOpt.all") holds the group's key: test the visible text. + expect(pill.textContent).toBe("plex.filter.showOpt.all"); + expect(pill.querySelector("svg")).not.toBeNull(); + expect(pill.getAttribute("title")).toBe("plex.filter.show"); + expect(pill.getAttribute("aria-label")).toMatch(/^plex\.filter\.show: /); + }); + + it("Sort shows a glyph, names itself, and ends the filters' row", async () => { + const pill = await mount(); + const sort = sortTrigger(); + expect(sort.textContent).not.toContain("plex.filter.sort"); + expect(sort.getAttribute("title")).toBe("plex.filter.sort"); + expect(pill.parentElement!.contains(sort)).toBe(true); + }); +}); diff --git a/frontend/src/modules/plex/PlexBrowse.tsx b/frontend/src/modules/plex/PlexBrowse.tsx index dd820682..86f31603 100644 --- a/frontend/src/modules/plex/PlexBrowse.tsx +++ b/frontend/src/modules/plex/PlexBrowse.tsx @@ -7,7 +7,7 @@ import { useQueryClient, type InfiniteData, } from "@tanstack/react-query"; -import { User, X } from "lucide-react"; +import { ArrowDownUp, Eye, User, X } from "lucide-react"; import { qk } from "../../lib/queryKeys"; import { mayReadAhead, NEXT_PAGE, nextPageOnClick } from "../../lib/nextPage"; import { healPlexField } from "../../lib/plexSearch"; @@ -556,15 +556,15 @@ function PlexBrowseView({ sub }: { sub: ReturnType ); } - // Sort is tightly bound to the grid below, so on the grid view it lives pinned to the right of the - // applied-filter chip row (directly above the cards), not on its own toolbar line — the same idiom as - // the Feed's `sortView`. The Discover/Browse toggle moved up to the header (next to the scope pill). + // Sort ends the toolbar's row, the same idiom as the Feed's `sortView`. The Discover/Browse toggle + // moved up to the header (next to the scope pill). const sortView = ( setSort(next ?? PLEX_SORT_FALLBACK)} label={t("plex.filter.sort")} + icon={} ascLabel={t("plex.filter.dir.asc")} descLabel={t("plex.filter.dir.desc")} /> @@ -634,30 +634,28 @@ function PlexBrowseView({ sub }: { sub: ReturnType while only the poster grid scrolls. */}
-
+
{/* Watch state — a top-level toolbar pill next to the count (moved out of the rail), - the same idiom as the feed's Watch-state FilterSelect. */} + the same idiom as the feed's Watch-state FilterSelect, named by its glyph. */} {!fold.folded && ( } testId="plex-show" options={showOptions} selected={[show]} onChange={(next) => setShow(next[0] ?? "all")} /> )} - {/* Folded, the band is one row: Sort + the view toggle join it on the right. */} - {fold.folded && ( -
{sortView}
- )} +
{sortView}
- {/* The controls row sits directly above the grid: the applied-filter chips flow from the - left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort + - the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */} + {/* The applied-filter chips, directly above the grid (overflowing to a "+N"/count pill so + they never crowd a line) — a row only while there is a chip: both render nothing when + nothing is applied, and `empty:hidden` takes the empty row's margin away with them. */} {!fold.folded && ( -
+
{/* The active personal view (if any) leads the applied-filter row, like the feed. */} onClearAll={clearAllFilters} className="flex-1" /> -
{sortView}
)}
From add7f7d167eb9a16b64cddd28d100c9914b1e687 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 04:22:20 +0100 Subject: [PATCH 08/13] feat(channel): on a tablet the header's words give their room to the tags Below xl the action row drops "Prio" (the stepper's tooltip names it) and the Subscribed button's word (a check glyph; its name stays in the aria-label). On an iPad at 820 px with touch-sized targets the tag lane grows from 29 px, a sliver of its control, to 130 px. --- docs/DESIGN.md | 1 + .../feed/ChannelPage.actions.dom.test.tsx | 97 +++++++++++++++++++ frontend/src/modules/feed/ChannelPage.tsx | 12 ++- 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5519d723..141f93eb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -831,3 +831,4 @@ of a full-width panel and nothing on the right. | 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age | | 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels | | 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos | +| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 | diff --git a/frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx b/frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx new file mode 100644 index 00000000..5a41055b --- /dev/null +++ b/frontend/src/modules/feed/ChannelPage.actions.dom.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +// +// The channel header's action row on a tablet. The tags beside Subscribed got the row's leftover width, +// and on an iPad (820 px, 44 px touch targets) that was 29 px: a sliver of the tag control. Below the +// widest windows the row's two words — "Prio" before the stepper and "Subscribed" on its button — give +// their room to the tags: the stepper names itself by its tooltip, and the button keeps its name for a +// screen reader and in its tooltip. +import type { ReactNode } from "react"; +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("../../features/navigation/navigationContext", () => ({ + useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }), +})); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), + useFeedFiltersActions: () => ({ setFilters: () => {} }), +})); +vi.mock("./Feed", () => ({ default: () => null })); +vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null })); +vi.mock("../../components/ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import ChannelPage from "./ChannelPage"; +import { api, type ChannelDetail, type Me } from "../../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +async function renderPage() { + vi.spyOn(api, "channelDetail").mockResolvedValue({ + id: "UCx", + title: "StarTalk", + subscribed: true, + blocked: false, + hidden: false, + priority: 0, + deep_requested: false, + backfill_done: true, + stored_videos: 10, + tag_ids: [], + } as unknown as ChannelDetail); + vi.spyOn(api, "tags").mockResolvedValue([] as never); + render( + + + , + ); + return waitFor(() => screen.getByRole("button", { name: "channel.subscribedState" })); +} + +// The word is laid out only from `xl` up: below it the element is `display: none`. +const onlyWide = (el: Element) => + el.classList.contains("hidden") && el.classList.contains("xl:inline"); + +describe("ChannelPage — the action row leaves the tags room on a tablet", () => { + it("shows Subscribed's word only on a wide window, and keeps the button's name", async () => { + const button = await renderPage(); + const word = [...button.querySelectorAll("span")].find( + (s) => s.textContent === "channel.subscribedState", + ); + expect(word).toBeTruthy(); + expect(onlyWide(word!)).toBe(true); + expect(button.getAttribute("title")).toBe("channel.unsubscribe"); + }); + + it("shows the Prio word only on a wide window; the stepper keeps its own name", async () => { + await renderPage(); + const word = screen.getByText("channels.cols.priority"); + expect(onlyWide(word)).toBe(true); + expect(screen.getByRole("group", { name: "channels.row.priorityHint" })).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index a68f8713..57e4895d 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -474,7 +474,10 @@ export default function ChannelPage({ /> {/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back and the name take that room, so at a tablet width the actions wrap onto a line of their - own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */} + own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). + Below `xl` the row's two words ("Prio", "Subscribed") give their room to the tags: on an + iPad (820 px, 44 px touch targets) the tag lane was left 29 px, a sliver of its control. + The stepper names itself by its tooltip, the button by its aria-label and tooltip. */}
- {t("channels.cols.priority")} + + {t("channels.cols.priority")} + bumpPriority(channelId, d)} @@ -571,9 +576,10 @@ export default function ChannelPage({ onClick={onUnsubscribe} loading={unsubscribe.isPending} title={t("channel.unsubscribe")} + aria-label={t("channel.subscribedState")} icon={} > - {t("channel.subscribedState")} + {t("channel.subscribedState")} ) : ( +
+ +
+
, + ); + expect(focusablesIn(container).map((el) => el.textContent)).toEqual(["reachable"]); + offsetParent.mockRestore(); + }); +}); + describe("useDialogChrome · initial focus", () => { it("lands on the first control by default", () => { // jsdom has no layout, so the visibility filter (offsetParent) would see nothing focusable. diff --git a/frontend/src/components/ui/dialogFocus.ts b/frontend/src/components/ui/dialogFocus.ts index 35a00c20..03687254 100644 --- a/frontend/src/components/ui/dialogFocus.ts +++ b/frontend/src/components/ui/dialogFocus.ts @@ -9,12 +9,15 @@ import { useEffect, useEffectEvent, useRef, useState, type RefObject } from "rea // 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. +// collapsed disclosure) so the trap never lands focus on something the user can't see, and an inert +// part's are not tabbable: counted, the trap's last control is one Tab never reaches. const FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; export const focusablesIn = (root: HTMLElement): HTMLElement[] => - [...root.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null); + [...root.querySelectorAll(FOCUSABLE)].filter( + (el) => el.offsetParent !== null && !el.closest("[inert]"), + ); /** A form's first field — where a dialog opened to fill something in takes the focus: its first * control, unless one is marked `data-initial-focus`. The mark is for a form with a control above diff --git a/frontend/src/features/player/PlayerModal.dom.test.tsx b/frontend/src/features/player/PlayerModal.dom.test.tsx index 5d008ef3..790cdc44 100644 --- a/frontend/src/features/player/PlayerModal.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.dom.test.tsx @@ -1699,6 +1699,64 @@ describe("PlayerModal — the docked bar's fullscreen", () => { }); }); +// The big view covers the card, and its controls stayed in the Tab order under it: past the bar's +// last button the focus went on, unseen, to the card's Previous, Download and Remove. +describe("PlayerModal — the card under the big view", () => { + const open = () => { + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} + onState={() => Promise.resolve()} + />, + ); + return screen.getByLabelText("player.fullscreen"); + }; + + it("takes the card out of reach while the view is big, and gives it back", () => { + const enter = open(); + expect(enter.closest("[inert]")).toBeNull(); + fireEvent.click(enter); + expect(enter.closest("[inert]")).not.toBeNull(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(enter.closest("[inert]")).toBeNull(); + }); + + it("walks a keyboard from the card's button to the way out, and back to where it was", () => { + const enter = open(); + act(() => enter.focus()); // jsdom calls every focus :focus-visible — a key's focus, here + fireEvent.click(enter); + expect(document.activeElement).toBe(screen.getByLabelText("player.exitMaximize")); + fireEvent.click(screen.getByLabelText("player.exitMaximize")); + expect(document.activeElement).toBe(enter); + }); + + it("does not leave a mouse on the way out, where a Space would leave the big view", () => { + const enter = open(); + act(() => enter.focus()); + const matches = vi.spyOn(enter, "matches").mockReturnValue(false); + fireEvent.click(enter); + matches.mockRestore(); + const card = screen.getByRole("dialog").querySelector("[tabindex='-1']"); + expect(document.activeElement).toBe(card); + fireEvent.click(screen.getByLabelText("player.exitMaximize")); + expect(document.activeElement).toBe(card); + }); + + it("does not strand the focus on the page when the bar goes with it", () => { + const enter = open(); + fireEvent.click(enter); + act(() => screen.getByLabelText("player.exitMaximize").focus()); + fireEvent.keyDown(window, { key: "Escape" }); + expect(document.activeElement).not.toBe(document.body); + expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true); + }); +}); + // Clip mode: the panel sits under the video (the video is its preview), Escape closes the panel and // not the player, the scissors gets the focus back; in the maximised view the panel takes the docked // bar's place; a crop frame lies over the video and Escape turns it off before the panel closes. diff --git a/frontend/src/features/player/PlayerModal.tsx b/frontend/src/features/player/PlayerModal.tsx index d5da4bb0..8e4ffa8f 100644 --- a/frontend/src/features/player/PlayerModal.tsx +++ b/frontend/src/features/player/PlayerModal.tsx @@ -450,7 +450,8 @@ export default function PlayerModal({ // above it ends there. const clipDockRef = useRef(null); const [clipDockH, setClipDockH] = useState(0); - const clipDocked = clipOpen && (maximized || isFullscreen); + const bigView = maximized || isFullscreen; + const clipDocked = clipOpen && bigView; // Its two places are two parents (the dock is IN the stage, the card's slot under it), and a panel // rendered in the other one is a new panel: the typed name, Loop and the focus were lost on every // F. So it renders once, into a host node of its own, and the host moves between the two. @@ -533,14 +534,37 @@ export default function PlayerModal({ const lost = document.activeElement == null || document.activeElement === document.body; if (!refocusClip.current && !lost) return; refocusClip.current = false; - const big = maximized || isFullscreen; - const target = big ? clipDockButtonRef.current : clipButtonRef.current; + const target = bigView ? clipDockButtonRef.current : clipButtonRef.current; if (target) target.focus(); else focusModal(); }); useEffect(() => { if (!clipOpen) refocusAfterClip(); }, [clipOpen]); + // The big view covers the card, so the card's part with the controls goes inert under it (see the + // markup) and the focus cannot stay there. A key's focus moves to the way out and comes back to + // the same control when the view ends. A mouse's goes to the card: on the way out, the next Space + // would leave the big view instead of pausing. A focus the view's end took with it (the bar's + // controls go with the bar) goes back to the card. After the clip panel's move above, which keeps + // its own focus. + const cardPanelRef = useRef(null); + const bigExitRef = useRef(null); + const focusBeforeBig = useRef(null); + useLayoutEffect(() => { + if (bigView) { + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !cardPanelRef.current?.contains(active)) return; + const byKey = active.matches(":focus-visible"); + focusBeforeBig.current = byKey ? active : null; + if (byKey && bigExitRef.current) bigExitRef.current.focus(); + else focusModal(); + return; + } + const back = focusBeforeBig.current; + focusBeforeBig.current = null; + if (back?.isConnected) back.focus(); + else reclaimFocus(); + }, [bigView, focusModal, reclaimFocus]); /** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The * panel lives under the video in the card, and in place of the docked bar when maximised; the * browser's fullscreen steps back to the maximised view, which has room for it. */ @@ -1151,7 +1175,7 @@ export default function PlayerModal({ which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
)} - {(maximized || isFullscreen) && !clipDocked && ( + {bigView && !clipDocked && (
e.stopPropagation()} className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center gap-x-3 border-t border-white/10 bg-media-chrome/95 media-marks px-3 text-sm text-white" @@ -1287,6 +1311,7 @@ export default function PlayerModal({ )} { e.stopPropagation(); @@ -1356,6 +1381,8 @@ export default function PlayerModal({ {/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long description never makes the card taller than the video. */}
From 8dd2f0c01511f761071d73931eb6b67ea319c40b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 18:26:33 +0100 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20review=20round=202=20=E2=80=94=20C?= =?UTF-8?q?lear=20watch=20progress=20clears=20only=20the=20resume=20point,?= =?UTF-8?q?=20big=20view=20focus=20by=20the=20way=20it=20arrived,=20dialog?= =?UTF-8?q?=20name,=20reason=20tooltip,=20backfill=20locks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/downloads/backfill.py | 14 +- backend/app/reader/service.py | 11 -- backend/app/routes/feed.py | 15 +- backend/tests/test_facebook_titles.py | 75 ++++++--- backend/tests/test_owner_scoping.py | 2 +- backend/tests/test_reader_service.py | 3 - backend/tests/test_route_commits.py | 6 +- backend/tests/test_takeout.py | 12 +- docs/DESIGN.md | 3 +- frontend/src/components/ui/Tooltip.tsx | 8 +- frontend/src/components/ui/unavailable.ts | 9 +- .../features/player/PlayerModal.dom.test.tsx | 142 ++++++++++++++++-- frontend/src/features/player/PlayerModal.tsx | 85 ++++++----- .../player/RelatedVideos.dom.test.tsx | 6 +- .../src/features/player/RelatedVideos.tsx | 20 +-- .../features/player/playerModalControls.tsx | 45 ++++-- .../video/VideoCard.actions.dom.test.tsx | 4 +- frontend/src/features/video/VideoCard.tsx | 2 +- .../src/features/video/VideoMenu.dom.test.tsx | 25 ++- .../video/useVideoActions.dom.test.tsx | 5 +- .../src/features/video/useVideoActions.tsx | 16 +- .../video/videoWrites.contract.test.ts | 2 +- .../features/video/videoWrites.dom.test.ts | 10 +- frontend/src/features/video/videoWrites.ts | 10 +- frontend/src/i18n/locales/en/card.json | 2 +- frontend/src/i18n/locales/en/errors.json | 2 +- frontend/src/i18n/locales/hu/card.json | 2 +- frontend/src/i18n/locales/hu/errors.json | 2 +- frontend/src/lib/api/feed.ts | 12 +- frontend/src/lib/menuGlyphs.contract.test.ts | 2 +- ...st.tsx => Feed.clearProgress.dom.test.tsx} | 50 +++--- frontend/src/modules/feed/Feed.tsx | 40 +++-- frontend/src/modules/feed/VirtualFeed.tsx | 6 +- frontend/src/modules/home/Home.dom.test.tsx | 8 +- frontend/src/modules/home/Home.tsx | 12 +- .../modules/playlists/Playlists.dom.test.tsx | 12 +- frontend/src/modules/playlists/Playlists.tsx | 20 +-- 37 files changed, 457 insertions(+), 243 deletions(-) rename frontend/src/modules/feed/{Feed.reset.dom.test.tsx => Feed.clearProgress.dom.test.tsx} (72%) diff --git a/backend/app/downloads/backfill.py b/backend/app/downloads/backfill.py index ac2293b4..a79ed4e2 100644 --- a/backend/app/downloads/backfill.py +++ b/backend/app/downloads/backfill.py @@ -89,14 +89,17 @@ def _retitle(db: Session, a: MediaAsset, adapter: adapters.SourceAdapter, url: s named = normalize_title(adapter.title(info)) if not named or adapter.is_placeholder_title(named): return False - # One conditional write, not a read and a compare: a name its owner gives while this runs (the - # poster step can take seconds before the commit) is not overwritten by what was read before it. + # One conditional write, not a read and a compare: a name its owner gives while the page is read + # is not overwritten by what was read before it. The asset first, as every other writer of the + # two locks them (`service.lock_own_job`): the session does not flush by itself. + placeholder = a.title + a.title = named + db.flush() db.execute( update(DownloadJob) - .where(DownloadJob.asset_id == a.id, DownloadJob.display_name == a.title) + .where(DownloadJob.asset_id == a.id, DownloadJob.display_name == placeholder) .values(display_name=named[:DISPLAY_TEXT_MAX]) ) - a.title = named return True @@ -127,6 +130,9 @@ def run() -> dict: if adapter.wants_metadata_backfill(): url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref) if _retitle(db, a, adapter, url): + # Before the poster step: it can cut a frame for 30 s, and these locks held + # across it block a rename and deadlock a delete. + db.commit() retitled += 1 log.info("asset %s retitled", a.id) # the title names what they downloaded if a.poster_path is None: diff --git a/backend/app/reader/service.py b/backend/app/reader/service.py index fa651005..1041b822 100644 --- a/backend/app/reader/service.py +++ b/backend/app/reader/service.py @@ -255,17 +255,6 @@ def mark(db: Session, user: User, video_id: str, status: str) -> None: ) -def clear_state(db: Session, user: User, video_id: str) -> None: - """The video back to pristine for this user: status, resume point and `watched_at` all go. - Playlist membership lives elsewhere and stays.""" - lock_takeout(db, user) # as `unmark` - row = queries.video_state(db, user, video_id) - if row is not None: - db.delete(row) - _remember_unmark(db, user, video_id) - db.flush() - - def set_progress(db: Session, user: User, video_id: str, position: int) -> None: """The player's resume point. 0 clears it: a row holding nothing else goes, one with a status (watched, hidden) keeps it with the position zeroed. The status itself is never changed here. diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index f36294dd..43581896 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -204,22 +204,21 @@ def set_video_state( return {"ok": True, "video_id": video_id, "status": status} -@router.delete("/videos/{video_id}/state") -def clear_video_state( +@router.delete("/videos/{video_id}/progress") +def clear_video_progress( video_id: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """Reset a video to pristine for this user — drop the whole VideoState row (status, - watch position and watched_at), as if it had never been opened. Saved/playlist membership - lives elsewhere and is untouched.""" + """Forget where the user left a video: its resume point goes, its status (watched, hidden) stays. + Saved/playlist membership lives elsewhere and is untouched.""" try: - service.clear_state(db, user, video_id) + service.set_progress(db, user, video_id, 0) db.commit() - # silent-ok: the row went under the delete, which is the outcome the delete wanted. + # silent-ok: the row went under the write, which is the outcome the write wanted. except StaleDataError: db.rollback() - return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0} + return {"ok": True, "video_id": video_id, "position_seconds": 0} @router.post("/videos/{video_id}/progress") diff --git a/backend/tests/test_facebook_titles.py b/backend/tests/test_facebook_titles.py index eb92db1f..25372183 100644 --- a/backend/tests/test_facebook_titles.py +++ b/backend/tests/test_facebook_titles.py @@ -6,9 +6,7 @@ no post text to fall back on, and the download name copied the placeholder too. the same reel (yt-dlp 2026.08.19, as on prod) still carries the post's text in its description. """ -import threading - -from sqlalchemy import update +from sqlalchemy import select, update from app.config import settings from app.db import SessionLocal @@ -149,8 +147,8 @@ def test_the_backfill_retitles_a_placeholder_download_and_the_names_that_repeate def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch): - # The owner renames the download between the backfill's retitle and its commit (the poster step - # sits between them): the owner's name wins, it is not overwritten by what was read before it. + # The owner renames the download while the backfill reads the page: the owner's name wins, it is + # not overwritten by what was read before it. _fake_ydl(monkeypatch, _PUBLIC) owner = make_user() asset = MediaAsset( @@ -178,7 +176,9 @@ def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch): db.add(job) db.commit() - def rename(): + read_page = backfill.public_title_info + + def renamed_while_reading(*args): with SessionLocal() as other: other.execute( update(DownloadJob) @@ -186,27 +186,64 @@ def test_a_name_given_while_the_backfill_runs_stays(db, make_user, monkeypatch): .values(display_name="Office jokes") ) other.commit() + return read_page(*args) - def poster_while_renamed(a, root): - # A rename that must wait for the backfill's write waits here, and lands after its commit. - t = threading.Thread(target=rename) - t.start() - t.join(timeout=0.5) - renamers.append(t) - return None - - renamers: list[threading.Thread] = [] - monkeypatch.setattr(backfill.editmod, "ensure_poster", poster_while_renamed) + monkeypatch.setattr(backfill, "public_title_info", renamed_while_reading) + monkeypatch.setattr(backfill.editmod, "ensure_poster", lambda a, root: None) assert backfill.run()["retitled"] == 1 - for t in renamers: - t.join(timeout=10) - assert not t.is_alive() db.expire_all() assert db.get(DownloadJob, job.id).display_name == "Office jokes" +def test_the_poster_step_holds_no_row_lock(db, make_user, monkeypatch): + # It can cut a frame for 30 s. A delete locks the asset and then the job: with the job held here + # and the asset still to write, that is a deadlock, and a rename waits out the whole cut. + _fake_ydl(monkeypatch, _PUBLIC) + owner = make_user() + asset = MediaAsset( + source_kind="url", + source_ref=_URL, + format_sig="fb", + status="ready", + rel_path="facebook/x.mp4", + uploader_url="https://www.facebook.com/1", + title="Facebook", + ref_count=1, + ) + db.add(asset) + db.flush() + job = DownloadJob( + user_id=owner.id, + asset_id=asset.id, + source_kind="url", + source_ref=_URL, + status="done", + queue_pos=0, + profile_snapshot={}, + display_name="Facebook", + ) + db.add(job) + db.commit() + asset_id, job_id = asset.id, job.id + + def poster_finds_rows_free(a, root): + with SessionLocal() as other: + other.execute( + select(MediaAsset).where(MediaAsset.id == asset_id).with_for_update(nowait=True) + ) + other.execute( + select(DownloadJob).where(DownloadJob.id == job_id).with_for_update(nowait=True) + ) + other.rollback() + return None + + monkeypatch.setattr(backfill.editmod, "ensure_poster", poster_finds_rows_free) + + assert backfill.run()["retitled"] == 1 + + def test_a_download_titled_facebook_is_named_from_the_signed_out_page( db, make_user, monkeypatch, tmp_path ): diff --git a/backend/tests/test_owner_scoping.py b/backend/tests/test_owner_scoping.py index c85a69de..51ccb83f 100644 --- a/backend/tests/test_owner_scoping.py +++ b/backend/tests/test_owner_scoping.py @@ -181,7 +181,7 @@ def test_clearing_a_video_leaves_another_users_state(db, make_user, as_user): _videos(db, "v1") db.add(VideoState(user_id=other.id, video_id="v1", status="hidden", position_seconds=90)) db.commit() - assert as_user(me).delete("/api/videos/v1/state").status_code == 200 + assert as_user(me).delete("/api/videos/v1/progress").status_code == 200 assert _video_state(db, other) == ("hidden", 90) diff --git a/backend/tests/test_reader_service.py b/backend/tests/test_reader_service.py index 97522c3d..63e4c0b8 100644 --- a/backend/tests/test_reader_service.py +++ b/backend/tests/test_reader_service.py @@ -190,9 +190,6 @@ def test_a_cleared_resume_point_drops_a_bare_row_and_zeroes_one_with_a_status(db db.commit() assert _state(db, user, "v1") is None assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0) - service.clear_state(db, user, "v2") - db.commit() - assert _state(db, user, "v2") is None # --- playlists ------------------------------------------------------------------------------- diff --git a/backend/tests/test_route_commits.py b/backend/tests/test_route_commits.py index 5dea6860..6491fec5 100644 --- a/backend/tests/test_route_commits.py +++ b/backend/tests/test_route_commits.py @@ -101,8 +101,10 @@ def test_a_video_state_cleared_through_the_route_is_gone(db, make_user, as_user) _video(db) db.add(VideoState(user_id=user.id, video_id="v1", status="hidden", position_seconds=90)) db.commit() - assert as_user(user).delete("/api/videos/v1/state").status_code == 200 - assert _state(db, user) is None + assert as_user(user).delete("/api/videos/v1/progress").status_code == 200 + db.expire_all() + row = _state(db, user) + assert (row.status, row.position_seconds) == ("hidden", 0) # --- saved views ------------------------------------------------------------------------------ diff --git a/backend/tests/test_takeout.py b/backend/tests/test_takeout.py index d63f3475..896b19ab 100644 --- a/backend/tests/test_takeout.py +++ b/backend/tests/test_takeout.py @@ -292,17 +292,16 @@ def test_a_known_video_is_marked_watched_at_its_takeout_time_and_the_rest_wait_o assert (states["older000000"].status, states["older000000"].position_seconds) == ("watched", 40) -@pytest.mark.parametrize("undo", ["unmark", "clear_state"]) -def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user, undo): - """Un-marking (or resetting) a video leaves no state row, and the import inserted a watched one - over the user's own choice. A view on YouTube after the un-mark is a new view, and counts.""" - user = make_user(f"{undo}@example.com") +def test_a_video_the_user_un_marked_here_is_not_marked_again_by_an_older_view(db, make_user): + """Un-marking a video leaves no state row, and the import inserted a watched one over the user's + own choice. A view on YouTube after the un-mark is a new view, and counts.""" + user = make_user("unmark@example.com") for vid in ("undone00000", "again000000"): _video(db, vid) db.commit() for vid in ("undone00000", "again000000"): service.mark(db, user, vid, "watched") - getattr(service, undo)(db, user, vid) + service.unmark(db, user, vid) db.commit() assert _states(db, user) == {} later = datetime.now(timezone.utc) + timedelta(minutes=5) @@ -603,7 +602,6 @@ def test_staging_and_settling_take_the_users_one_lock(db, make_user): ), # Between the import's read of the un-marks and its marking, one would be overwritten. "unmark": lambda s, u: service.unmark(s, u, "aaaaaaaaaaa"), - "reset": lambda s, u: service.clear_state(s, u, "aaaaaaaaaaa"), } with SessionLocal() as holder: service.lock_takeout(holder, holder.get(User, user.id)) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ea073709..c173a62e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -256,7 +256,7 @@ Also in force: - **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the refresh arrow (`RefreshCw`), and so is doing a download again; a reset or revert is the counter-clockwise arrow (`RotateCcw`), and so is playing from the beginning, on the card and in its - menu alike; clearing a video's progress and status is the eraser (`Eraser`). A failed download's Retry + menu alike; clearing a video's watch progress is the eraser (`Eraser`). A failed download's Retry had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous / Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous, `Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the @@ -833,3 +833,4 @@ of a full-width panel and nothing on the right. | 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos | | 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 | | 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo | +| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | diff --git a/frontend/src/components/ui/Tooltip.tsx b/frontend/src/components/ui/Tooltip.tsx index 6ce58ff6..78e17813 100644 --- a/frontend/src/components/ui/Tooltip.tsx +++ b/frontend/src/components/ui/Tooltip.tsx @@ -11,18 +11,22 @@ type Side = "top" | "bottom" | "left" | "right"; * is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room * and slides back inside the viewport near an edge. * `always` bypasses the hints toggle (for a label that IS the primary affordance, e.g. the slim nav - * rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel). */ + * rail's flyout names); `openDelay` waits before showing (a rest-on-the-icon feel); `stable` keeps + * the wrapper with no hint to show, so a hint that comes and goes does not remount the element and + * cost it the focus. */ export default function Tooltip({ hint, side = "top", always = false, openDelay = 0, + stable = false, children, }: { hint: string; side?: Side; always?: boolean; openDelay?: number; + stable?: boolean; children: React.ReactNode; }) { const hintsOn = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); @@ -42,7 +46,7 @@ export default function Tooltip({ }); const { setReference } = pop.refs; - if (!enabled || !hint) return <>{children}; + if ((!enabled || !hint) && !stable) return <>{children}; return ( <> diff --git a/frontend/src/components/ui/unavailable.ts b/frontend/src/components/ui/unavailable.ts index 3c164467..5504c83b 100644 --- a/frontend/src/components/ui/unavailable.ts +++ b/frontend/src/components/ui/unavailable.ts @@ -1,7 +1,10 @@ /** An unavailable control's props. With a reason it stays focusable (`aria-disabled`, which - * `FocusSafeButton` keeps inert to a click, and the reason in its tooltip — DESIGN.md "Unavailable - * with a reason stays focusable"); without one it is plainly off. */ + * `FocusSafeButton` keeps inert to a click, and the reason read out with its name; the caller shows + * it in a tooltip — DESIGN.md "Unavailable with a reason stays focusable"); without one it is + * plainly off. */ export function unavailable(disabled: boolean | undefined, reason: string | undefined) { if (!disabled) return {}; - return reason ? { "aria-disabled": true as const } : { disabled: true }; + return reason + ? { "aria-disabled": true as const, "aria-description": reason } + : { disabled: true }; } diff --git a/frontend/src/features/player/PlayerModal.dom.test.tsx b/frontend/src/features/player/PlayerModal.dom.test.tsx index 790cdc44..1edf96b3 100644 --- a/frontend/src/features/player/PlayerModal.dom.test.tsx +++ b/frontend/src/features/player/PlayerModal.dom.test.tsx @@ -1050,14 +1050,14 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(lastStart()?.start ?? 0).toBe(0); }); - // A reset from the related row is where it starts now: the position this session stored for it + // A clear from the related row is where it starts now: the position this session stored for it // (which a plain pick resumes from) is forgotten with it, not replayed and checkpointed back. - it("wide: a related video reset from its row starts at 0 on a plain pick", async () => { + it("wide: a related video cleared from its row starts at 0 on a plain pick", async () => { vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({ video_id: id, position_seconds: id === "rel2" ? 500 : 77, })); - const reset = vi.spyOn(api, "clearState").mockResolvedValue(undefined as never); + const reset = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never); clock.current = 77; await playRel2(() => Promise.resolve()); clock.current = 500; @@ -1070,7 +1070,7 @@ describe("PlayerModal — the side panel on a wide window", () => { fireEvent.click( screen .queryAllByRole("menuitem", { hidden: true }) - .find((b) => b.textContent === "card.resetState")!, + .find((b) => b.textContent === "card.clearProgress")!, ); await waitFor(() => expect(reset).toHaveBeenCalledWith("rel2")); await settle(); @@ -1079,6 +1079,44 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(lastStart()?.start ?? 0).toBe(0); }); + // The queue was read when the player opened, and a video in it can be in the related list too: a + // step to it after the clear starts at 0, not at that older position, which its first checkpoint + // would then write back over the clear. + it("wide: a queue item cleared from its related row starts at 0 on a step to it", async () => { + wideWindow(true); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + vi.spyOn(api, "related").mockResolvedValue({ + items: [related("rel2", 120)], + has_more: false, + next_cursor: null, + }); + const clear = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never); + const A = vid("A", 40); + renderModal( + {}} + onState={() => Promise.resolve()} + />, + ); + act(() => playerEvents.onReady?.()); + fireEvent.click(screen.getByRole("tab", { name: "player.related.title" })); + const links = await screen.findAllByRole("link", { name: "Video rel2" }); + fireEvent.contextMenu(links[0]!.closest("[data-video-id]")!); + fireEvent.click( + screen + .queryAllByRole("menuitem", { hidden: true }) + .find((b) => b.textContent === "card.clearProgress")!, + ); + await waitFor(() => expect(clear).toHaveBeenCalledWith("rel2")); + await settle(); + fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!); + await waitFor(() => expect(lastStart()?.videoId).toBe("rel2")); + expect(lastStart()?.start ?? 0).toBe(0); + }); + // Its row stays in the list while it plays; a new player for the same video is never built (one // per video), so Restart there is a seek. it("wide: Restart on the related video already playing seeks it to 0", async () => { @@ -1702,13 +1740,14 @@ describe("PlayerModal — the docked bar's fullscreen", () => { // The big view covers the card, and its controls stayed in the Tab order under it: past the bar's // last button the focus went on, unseen, to the card's Previous, Download and Remove. describe("PlayerModal — the card under the big view", () => { - const open = () => { + const open = (others: Video[] = []) => { vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + vi.spyOn(api, "saveProgress").mockResolvedValue({ video_id: "A", position_seconds: 0 }); const A = vid("A", 0); renderModal( {}} onState={() => Promise.resolve()} @@ -1737,16 +1776,68 @@ describe("PlayerModal — the card under the big view", () => { it("does not leave a mouse on the way out, where a Space would leave the big view", () => { const enter = open(); - act(() => enter.focus()); const matches = vi.spyOn(enter, "matches").mockReturnValue(false); - fireEvent.click(enter); + act(() => enter.focus()); matches.mockRestore(); + fireEvent.click(enter); const card = screen.getByRole("dialog").querySelector("[tabindex='-1']"); expect(document.activeElement).toBe(card); fireEvent.click(screen.getByLabelText("player.exitMaximize")); expect(document.activeElement).toBe(card); }); + // Chrome turns a clicked button :focus-visible the moment a key is pressed, so the F that opens the + // big view cannot be what tells a mouse's focus from a key's. + it("keeps a click's focus a mouse's when F opens the view", () => { + open(); + const clicked = screen.getByLabelText("player.markWatched"); + const matches = vi.spyOn(clicked, "matches").mockReturnValue(false); + act(() => clicked.focus()); + matches.mockRestore(); // from here it answers true, as Chrome does after the key + fireEvent.keyDown(window, { key: "f" }); + const card = screen.getByRole("dialog").querySelector("[tabindex='-1']"); + expect(document.activeElement).toBe(card); + }); + + // A step to the queue's end in the big view turned the remembered Next off: it refuses the focus. + it("gives the card the focus when the control it came from has gone off", async () => { + open([vid("B", 0)]); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + const next = screen.getByRole("button", { name: "player.next" }); + act(() => next.focus()); + fireEvent.keyDown(window, { key: "f" }); + expect(document.activeElement).toBe(screen.getByLabelText("player.exitMaximize")); + fireEvent.keyDown(window, { key: "ArrowRight", shiftKey: true }); + await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + fireEvent.keyDown(window, { key: "Escape" }); + expect((next as HTMLButtonElement).disabled).toBe(true); + const card = screen.getByRole("dialog").querySelector("[tabindex='-1']"); + expect(document.activeElement).toBe(card); + }); + + // The headings sit in the part that goes inert, and an inert element names nothing (measured in + // Chromium's accessibility tree; jsdom's would still give the name). + it("names the dialog from outside the part that goes inert", () => { + const enter = open(); + const id = screen.getByRole("dialog").getAttribute("aria-labelledby")!; + fireEvent.click(enter); + const names = document.querySelectorAll(`[id="${id}"]`); + expect(names).toHaveLength(1); + expect(names[0]!.closest("[inert]")).toBeNull(); + expect(names[0]!.textContent).toBe("Video A"); + }); + + // A divider squeezed to nothing where the bar's row overflows and scrolls. + it("keeps the bar's dividers when its row scrolls", () => { + fireEvent.click(open([vid("B", 0)])); + const scroller = screen + .getByRole("button", { name: "player.exitMaximize" }) + .parentElement!.querySelector(".overflow-x-auto")!; + const dividers = scroller.querySelectorAll("span.w-px"); + expect(dividers.length).toBeGreaterThan(1); + for (const d of dividers) expect(d.className).toContain("shrink-0"); + }); + it("does not strand the focus on the page when the bar goes with it", () => { const enter = open(); fireEvent.click(enter); @@ -1864,6 +1955,19 @@ describe("PlayerModal — clip mode", () => { expect(screen.queryByTestId("clip-panel")).toBeNull(); }); + it("leaves the focus in the panel when the view ends, whatever it came from", async () => { + open(); + await waitFor(() => expect(lastStart()?.videoId).toBe("A")); + act(() => screen.getByLabelText("player.markWatched").focus()); + key("f"); + key("c"); + const panel = screen.getByTestId("clip-panel"); + const inPanel = panel.querySelector("button:not([disabled])")!; + act(() => inPanel.focus()); + key("f"); + expect(document.activeElement).toBe(inPanel); + }); + it("gives the focus back to the scissors the user can see: in the maximised view, the bar's", async () => { open(); await waitFor(() => expect(lastStart()?.videoId).toBe("A")); @@ -1999,8 +2103,10 @@ describe("PlayerModal — stepping over a video that cannot play", () => { for (const b of prevs) { expect((b as HTMLButtonElement).disabled).toBe(false); expect(b.getAttribute("aria-disabled")).toBe("true"); - expect(b.getAttribute("title")).toContain("player.noPlayableBefore"); } + // Where a key's focus can read it: a browser's own title shows for a mouse alone. + act(() => prevs[0]!.focus()); + expect(await screen.findByText(/player\.noPlayableBefore/)).toBeTruthy(); prev(); await settle(); expect(lastStart()?.videoId).toBe("B"); @@ -2009,6 +2115,24 @@ describe("PlayerModal — stepping over a video that cannot play", () => { expect(b.getAttribute("title")).not.toContain("player.noPlayableAfter"); }); + // The Next that the queue's end turned off keeps the focus; a step back gives it a reason, and the + // button that holds the focus must be the same button afterwards. + it("keeps the focus on a control when its reason arrives", async () => { + await open(0); + next(); + await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + const button = screen.getByRole("button", { name: "player.next" }); + act(() => button.focus()); + next(); + await waitFor(() => expect(lastStart()?.videoId).toBe("C")); + fail(); + await settle(); + prev(); + await waitFor(() => expect(lastStart()?.videoId).toBe("B")); + expect(button.getAttribute("aria-disabled")).toBe("true"); + expect(document.activeElement).toBe(button); + }); + it("Previous at the queue's start is plainly off: the counter says why", async () => { await open(0); for (const b of screen.getAllByRole("button", { name: "player.previous" })) { diff --git a/frontend/src/features/player/PlayerModal.tsx b/frontend/src/features/player/PlayerModal.tsx index 8e4ffa8f..824b07db 100644 --- a/frontend/src/features/player/PlayerModal.tsx +++ b/frontend/src/features/player/PlayerModal.tsx @@ -14,7 +14,7 @@ import { useNavigationActions } from "../navigation/navigationContext"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; import { usePref } from "../../lib/usePref"; -import { PlayerDescription, PlayerTransportControls } from "./playerModalControls"; +import { PlayerDescription, PlayerTransportControls, ReasonTooltip } from "./playerModalControls"; import { unavailable } from "../../components/ui/unavailable"; import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes"; import { @@ -544,17 +544,20 @@ export default function PlayerModal({ // The big view covers the card, so the card's part with the controls goes inert under it (see the // markup) and the focus cannot stay there. A key's focus moves to the way out and comes back to // the same control when the view ends. A mouse's goes to the card: on the way out, the next Space - // would leave the big view instead of pausing. A focus the view's end took with it (the bar's - // controls go with the bar) goes back to the card. After the clip panel's move above, which keeps - // its own focus. + // would leave the big view instead of pausing. Which it was is read as the focus arrives: Chrome + // calls a clicked button :focus-visible once any key is pressed, the F that opens the view too. + // When the view ends, a focus that has a place of its own stays there (the clip panel's, kept by + // its move above); one with none goes back to the card when the control refuses it (a Next that a + // step to the queue's end turned off). The bar's controls go with the bar. const cardPanelRef = useRef(null); const bigExitRef = useRef(null); const focusBeforeBig = useRef(null); + const panelFocusByKey = useRef(false); useLayoutEffect(() => { if (bigView) { const active = document.activeElement; if (!(active instanceof HTMLElement) || !cardPanelRef.current?.contains(active)) return; - const byKey = active.matches(":focus-visible"); + const byKey = panelFocusByKey.current; focusBeforeBig.current = byKey ? active : null; if (byKey && bigExitRef.current) bigExitRef.current.focus(); else focusModal(); @@ -562,8 +565,9 @@ export default function PlayerModal({ } const back = focusBeforeBig.current; focusBeforeBig.current = null; - if (back?.isConnected) back.focus(); - else reclaimFocus(); + const active = document.activeElement; + if (active === document.body || active === cardRef.current) back?.focus(); + if (document.activeElement !== back) reclaimFocus(); }, [bigView, focusModal, reclaimFocus]); /** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The * panel lives under the video in the card, and in place of the docked bar when maximised; the @@ -859,9 +863,9 @@ export default function PlayerModal({ // Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only // be refused. const relatedOn = useMe().related_enabled; - // A video reset from the related list starts over: the position this session stored for it would + // A video cleared from the related list starts over: the position this session stored for it would // otherwise resume a plain pick there, and the player's close would patch it back into the lists. - const forgetPosition = (id: string) => void sessionPosRef.current.delete(id); + const clearPosition = (id: string) => void sessionPosRef.current.set(id, 0); const playRelated = (v: Video, startAt: number | null) => { setRelatedOpen(false); // The video already playing (its row stays in the list): a new player for the same id would @@ -1124,6 +1128,11 @@ export default function PlayerModal({ aria-modal="true" aria-labelledby={titleId} > + {/* What names the dialog: the headings go inert with the card's panel in the big view, and an + inert element names nothing. Hidden, so it is read as the name and only as that. */} + {/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width for a second copy of the same two buttons (owner). */} @@ -1261,7 +1270,7 @@ export default function PlayerModal({ onCycleAuto={cycleAuto} onCycleLoop={cycleLoop} /> - + )} {/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both @@ -1383,6 +1392,9 @@ export default function PlayerModal({
{ + panelFocusByKey.current = e.target.matches(":focus-visible"); + }} className={wide ? "relative shrink-0 border-l border-border" : undefined} style={wide ? { width: panelWidth } : undefined} > @@ -1391,10 +1403,7 @@ export default function PlayerModal({ data-testid="player-strip" className="absolute inset-0 flex flex-col items-center gap-1 py-2" > - {/* The dialog is named by this heading's id: the panel's own one is not rendered. */} -

- {heading} -

+

{heading}

{closeButton} - - - - - - + + + + + + + + + + )}
@@ -1474,9 +1487,7 @@ export default function PlayerModal({ {/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the right (wide, it heads the panel). */}
-

- {heading} -

+

{heading}

{!wide && ( )} @@ -1769,7 +1780,7 @@ export default function PlayerModal({ videoId={active.id} onPlay={playRelated} onWrite={onWrite} - onReset={forgetPosition} + onCleared={clearPosition} />
)} diff --git a/frontend/src/features/player/RelatedVideos.dom.test.tsx b/frontend/src/features/player/RelatedVideos.dom.test.tsx index 62f3c044..c67462f3 100644 --- a/frontend/src/features/player/RelatedVideos.dom.test.tsx +++ b/frontend/src/features/player/RelatedVideos.dom.test.tsx @@ -163,12 +163,12 @@ describe("RelatedVideos", () => { await waitFor(() => expect(titles()).toEqual(["Video A"])); }); - it("resets a started video's view from its menu", async () => { + it("clears a started video's progress from its menu", async () => { vi.spyOn(api, "related").mockResolvedValue(page([row("A", { position_seconds: 95 })])); - const reset = vi.spyOn(api, "clearState").mockResolvedValue(undefined as never); + const reset = vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never); setup(); fireEvent.contextMenu((await title("Video A")).closest("[data-video-id]")!); - fireEvent.click(menuItem("card.resetState")); + fireEvent.click(menuItem("card.clearProgress")); await waitFor(() => expect(reset).toHaveBeenCalledWith("A")); }); diff --git a/frontend/src/features/player/RelatedVideos.tsx b/frontend/src/features/player/RelatedVideos.tsx index 24dbf9bd..5cd7448b 100644 --- a/frontend/src/features/player/RelatedVideos.tsx +++ b/frontend/src/features/player/RelatedVideos.tsx @@ -25,7 +25,7 @@ import Tooltip from "../../components/ui/Tooltip"; import VideoCard from "../video/VideoCard"; import { VideoMenu } from "../video/VideoMenu"; import { announceVideoState } from "../video/stateNotice"; -import { resetVideoState, writeVideoState, writeWatchLater } from "../video/videoWrites"; +import { clearVideoProgress, writeVideoState, writeWatchLater } from "../video/videoWrites"; import { msg } from "../../lib/notifications"; /** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood, @@ -54,15 +54,15 @@ export function RelatedVideos({ videoId, onPlay, onWrite, - onReset, + onCleared, }: { videoId: string; /** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */ onPlay: (v: Video, startAt: number | null) => void; /** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */ onWrite?: (write: Promise) => void; - /** A video's view was reset from its row: the host forgets where its session left it. */ - onReset?: (id: string) => void; + /** A video's progress was cleared from its row: the host forgets where its session left it. */ + onCleared?: (id: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -109,10 +109,10 @@ export function RelatedVideos({ }); }; - const onResetState = (id: string) => { - onReset?.(id); - const seq = begin(id, { status: "new", position_seconds: 0 }); - const write = resetVideoState(qc, id); + const onClearProgress = (id: string) => { + onCleared?.(id); + const seq = begin(id, { position_seconds: 0 }); + const write = clearVideoProgress(qc, id); onWrite?.(write); write .then(() => { @@ -120,7 +120,7 @@ export function RelatedVideos({ void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); }) .catch((e: unknown) => { - notifyActionError(e, msg("errors.writeFailed.videoReset")); + notifyActionError(e, msg("errors.writeFailed.videoProgress")); settle(seq); }); }; @@ -250,7 +250,7 @@ export function RelatedVideos({ )} )} - + ); } diff --git a/frontend/src/features/player/playerModalControls.tsx b/frontend/src/features/player/playerModalControls.tsx index 0134f50d..16f39214 100644 --- a/frontend/src/features/player/playerModalControls.tsx +++ b/frontend/src/features/player/playerModalControls.tsx @@ -4,6 +4,7 @@ import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } f import type { VideoDetail } from "../../lib/api"; import type { AutoMode, LoopMode } from "../../lib/playerModes"; import { IconButton } from "../../components/ui/IconButton"; +import Tooltip from "../../components/ui/Tooltip"; import { StateMessage } from "../../components/ui/QueryState"; import { unavailable } from "../../components/ui/unavailable"; import { ClampedText } from "../../components/ui/ClampedText"; @@ -13,6 +14,25 @@ import { renderDescription } from "./descriptionLinks"; // the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props. // The auto-advance / loop modes it renders are defined in `lib/playerModes`. +/** Why a control is off, where a key's focus reads it too: a browser's own tooltip (`title`, left on + * the button for what it does) shows for a mouse alone. `stable`: the reason comes and goes under a + * control that may hold the focus. */ +export function ReasonTooltip({ + reason, + side, + children, +}: { + reason: string | undefined; + side?: "left"; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + /** The queue transport cluster — prev / counter / next, then the auto-advance and loop modes. * Rendered in two places from ONE list of controls: the modal's queue bar (`variant:"modal"`, normal * glass theme) and the big-view docked control bar (`variant:"docked"`, white-on-dark). Glyphs only @@ -60,7 +80,7 @@ export function PlayerTransportControls({ const docked = variant === "docked"; // Never broken or squeezed: in a row that scrolls, a squeezed "1 / 55" stood on three lines. const counter = `shrink-0 whitespace-nowrap tabular-nums ${docked ? "text-white/70" : "text-muted"}`; - const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; + const divider = `h-4 w-px shrink-0 ${docked ? "bg-white/20" : "bg-border"}`; const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`; const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`; type Control = { @@ -114,17 +134,18 @@ export function PlayerTransportControls({ }, ]; const render = (c: Control) => ( - - {c.glyph} - + + + {c.glyph} + + ); return ( <> diff --git a/frontend/src/features/video/VideoCard.actions.dom.test.tsx b/frontend/src/features/video/VideoCard.actions.dom.test.tsx index 08fc5393..9a128b96 100644 --- a/frontend/src/features/video/VideoCard.actions.dom.test.tsx +++ b/frontend/src/features/video/VideoCard.actions.dom.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // // The card shows its state and the everyday toggles (watched, save, add to playlist) and a -// "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view +// "…" into the list's ONE menu; download, details, hide and clear progress live only in that menu. Every view // carries the same set, and a view that shows the "…" twice (the card: in the hover row on the // picture, and beside the title for touch — only one is displayed per device) points both at the // same menu. @@ -77,7 +77,7 @@ describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions" for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"]) expect(all).toContain(want); expect(all).toContain("card.menu.more"); - for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"]) + for (const gone of ["downloads.button.label", "card.hide", "card.clearProgress", "card.info"]) expect(all).not.toContain(gone); }); diff --git a/frontend/src/features/video/VideoCard.tsx b/frontend/src/features/video/VideoCard.tsx index 6a892e30..eafa32d6 100644 --- a/frontend/src/features/video/VideoCard.tsx +++ b/frontend/src/features/video/VideoCard.tsx @@ -49,7 +49,7 @@ import RemoteImage from "../../components/ui/RemoteImage"; type More = { props: Record; open: boolean }; // The card keeps its state and the everyday toggles — watched, save, add to playlist — and -// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE +// a "…" for everything else (download, details, copy, hide, clear progress, pin), which is the list's ONE // menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles // are gone (the "…" and the long-press are the way in) and only the "…" stays. /** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */ diff --git a/frontend/src/features/video/VideoMenu.dom.test.tsx b/frontend/src/features/video/VideoMenu.dom.test.tsx index 99dd2ac8..ca75da34 100644 --- a/frontend/src/features/video/VideoMenu.dom.test.tsx +++ b/frontend/src/features/video/VideoMenu.dom.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // // A video's ONE action list. The right-click and the card's "…" open the same menu, which -// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the +// holds everything the card no longer shows (download, details, copy, hide, clear progress, pin) beside the // everyday toggles, and each row fires the same handler the card's own buttons do. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; @@ -102,18 +102,18 @@ function setup(v: Video) { const handlers = { onState: vi.fn(), onToggleSave: vi.fn(), - onResetState: vi.fn(), + onClearProgress: vi.fn(), onOpen: vi.fn(), }; const qc = new QueryClient(); - render( + const { unmount } = render( , ); - return { handlers }; + return { handlers, unmount }; } const rightClick = () => fireEvent.contextMenu(screen.getByText("card")); const more = () => screen.getByRole("button", { name: "more" }); @@ -137,16 +137,29 @@ describe("a video's menu", () => { ]); }); - it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => { + it("offers Continue and the restart on a half-watched one, the way back and the clearing", async () => { const { handlers } = setup(video({ position_seconds: 30, saved: true })); rightClick(); expect(labels()).toContain("card.restartTitle"); expect(labels()).toContain("card.menu.unsave"); - expect(labels()).toContain("card.resetState"); + expect(labels()).toContain("card.clearProgress"); fireEvent.click(item("card.restartTitle")); await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0)); }); + // It clears a resume point and nothing else: with none, there is nothing for it to do. + it("offers clearing only where there is a resume point, whatever the status", () => { + for (const status of ["hidden", "watched"] as const) { + const { unmount } = setup(video({ status })); + rightClick(); + expect(labels()).not.toContain("card.clearProgress"); + unmount(); + } + setup(video({ status: "hidden", position_seconds: 30 })); + rightClick(); + expect(labels()).toContain("card.clearProgress"); + }); + it("each toggle fires the card's own handler", async () => { const { handlers } = setup(video({ status: "watched" })); rightClick(); diff --git a/frontend/src/features/video/useVideoActions.dom.test.tsx b/frontend/src/features/video/useVideoActions.dom.test.tsx index 1f72e517..f1ca38ec 100644 --- a/frontend/src/features/video/useVideoActions.dom.test.tsx +++ b/frontend/src/features/video/useVideoActions.dom.test.tsx @@ -39,7 +39,8 @@ function downloadRow(state: string | undefined) { {children} ); const { result } = renderHook( - () => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() }, true), + () => + useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn(), onClearProgress: vi.fn() }, true), { wrapper }, ); return () => result.current.items(video, null).find((i) => i.id === "download")!; @@ -74,7 +75,7 @@ describe("the video menu's channel-filter row", () => { { onState: vi.fn(), onToggleSave: vi.fn(), - onResetState: vi.fn(), + onClearProgress: vi.fn(), channelFilter: ids ? { ids, set } : undefined, }, true, diff --git a/frontend/src/features/video/useVideoActions.tsx b/frontend/src/features/video/useVideoActions.tsx index bd5e3b08..72517f6e 100644 --- a/frontend/src/features/video/useVideoActions.tsx +++ b/frontend/src/features/video/useVideoActions.tsx @@ -20,7 +20,7 @@ import { } from "lucide-react"; import { api, type Video, type VideoStatus } from "../../lib/api"; -import { isResettable } from "../../lib/api/feed"; +import { hasResumePoint } from "../../lib/api/feed"; import { qk } from "../../lib/queryKeys"; import { pinVideo, useComfortPin } from "../comfort/comfortPin"; import { useCopyItems } from "../../components/ui/copyItems"; @@ -33,13 +33,13 @@ import { raw } from "../../lib/notifications"; // long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page // and the Home shelves list the same actions in the same order. The card itself keeps only its // state and the everyday toggles (watched, save, add to playlist) on hover; everything else — -// download, details, copy, hide, reset, pin — lives here, and only here. +// download, details, copy, hide, clear progress, pin — lives here, and only here. export interface VideoActionHandlers { onState: (id: string, status: VideoStatus) => void; onToggleSave: (id: string, saved: boolean) => void; /** Back to fresh: its state row and resume point both go. */ - onResetState: (id: string) => void; + onClearProgress: (id: string) => void; /** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */ onOpen?: (v: Video, startAt?: number | null) => void; /** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel @@ -71,7 +71,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { anchor: Element | null, { pin: withPin = true }: { pin?: boolean } = {}, ): ActionMenuItem[] => { - const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers; + const { onState, onToggleSave, onClearProgress, onOpen, channelFilter } = handlers; const watched = v.status === "watched"; const hidden = v.status === "hidden"; const inProgress = v.position_seconds > 0 && !watched; @@ -189,13 +189,13 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) { separatorBefore: true, onSelect: () => onState(v.id, hidden ? "new" : "hidden"), }, - ...(isResettable(v) + ...(hasResumePoint(v) ? [ { - id: "reset", - label: t("card.resetState"), + id: "clearProgress", + label: t("card.clearProgress"), icon: , - onSelect: () => onResetState(v.id), + onSelect: () => onClearProgress(v.id), }, ] : []), diff --git a/frontend/src/features/video/videoWrites.contract.test.ts b/frontend/src/features/video/videoWrites.contract.test.ts index 0a29000e..252bf91b 100644 --- a/frontend/src/features/video/videoWrites.contract.test.ts +++ b/frontend/src/features/video/videoWrites.contract.test.ts @@ -21,7 +21,7 @@ const ALLOWED = new Set([ // the built-in Watch later list's items ARE the mark. videoWrites makes those writes and the mark // together; a direct api call skips the mark. const WRITES = - /\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/; + /\bapi\s*\.\s*(setState|clearProgress|watchLaterAdd|watchLaterRemove|addToPlaylist|removeFromPlaylist|moveToPlaylist)\b/; describe("a video's state, Watch-later and playlist-item writes", () => { it("go through features/video/videoWrites, never the api directly", () => { diff --git a/frontend/src/features/video/videoWrites.dom.test.ts b/frontend/src/features/video/videoWrites.dom.test.ts index 5aa52125..aa10f1ef 100644 --- a/frontend/src/features/video/videoWrites.dom.test.ts +++ b/frontend/src/features/video/videoWrites.dom.test.ts @@ -11,7 +11,7 @@ import { addPlaylistItem, movePlaylistItem, removePlaylistItem, - resetVideoState, + clearVideoProgress, writeVideoState, writeWatchLater, } from "./videoWrites"; @@ -41,11 +41,11 @@ describe("videoWrites", () => { expect(marks(qc)).toEqual({ X: { status: "hidden" } }); }); - it("marks a reset as new, from the start", async () => { - vi.spyOn(api, "clearState").mockResolvedValue({ ok: true } as never); + it("marks a cleared video as back at the start, its status as it was", async () => { + vi.spyOn(api, "clearProgress").mockResolvedValue({ ok: true } as never); const qc = new QueryClient(); - await resetVideoState(qc, "X"); - expect(marks(qc)).toEqual({ X: { status: "new", position_seconds: 0 } }); + await clearVideoProgress(qc, "X"); + expect(marks(qc)).toEqual({ X: { position_seconds: 0 } }); }); it("marks a Watch-later save and its removal", async () => { diff --git a/frontend/src/features/video/videoWrites.ts b/frontend/src/features/video/videoWrites.ts index 38fa22f0..4f928095 100644 --- a/frontend/src/features/video/videoWrites.ts +++ b/frontend/src/features/video/videoWrites.ts @@ -7,7 +7,7 @@ import { markVideo } from "../../lib/progressCache"; // once it has the change — into the lists that are never refetched (`markVideo`: every related // list, every live search). These are the ONE way to make those writes, and // `videoWrites.contract.test.ts` holds it: four writes that called the api directly (the -// Notification Center's undo, the feed's reset, the feed's and Home's Watch-later toggles) left a +// Notification Center's undo, the feed's clear, the feed's and Home's Watch-later toggles) left a // related list hiding a video the user had un-hidden, or showing it unsaved after a save. A // playlist item is one of them because the built-in Watch later list IS the mark (`saved`): ticked // in Add to playlist or taken out on the Playlists page, it left the bookmark as it was. Each @@ -20,10 +20,10 @@ export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus }); } -/** Back to pristine: the state row and its resume position both go. */ -export function resetVideoState(qc: QueryClient, id: string) { - return api.clearState(id).then((r) => { - markVideo(qc, id, { status: "new", position_seconds: 0 }); +/** Forget where the user left it: the resume point goes, the status stays. */ +export function clearVideoProgress(qc: QueryClient, id: string) { + return api.clearProgress(id).then((r) => { + markVideo(qc, id, { position_seconds: 0 }); return r; }); } diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json index 47ef1ec8..e566dbed 100644 --- a/frontend/src/i18n/locales/en/card.json +++ b/frontend/src/i18n/locales/en/card.json @@ -8,7 +8,7 @@ "saveForLater": "Save for later", "unhide": "Unhide", "hide": "Hide", - "resetState": "Clear watch progress", + "clearProgress": "Clear watch progress", "thisChannel": "This channel", "continueTitle": "Continue where you left off", "continue": "Continue", diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index e9e86c90..f4f8cbfe 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -96,7 +96,7 @@ "prefs": "Couldn't save your settings", "deleteAccount": "Couldn't delete your account", "videoState": "Couldn't change the video's state", - "videoReset": "Couldn't reset the video", + "videoProgress": "Couldn't clear the video's watch progress", "watchLaterAdd": "Couldn't add it to Watch later", "watchLaterRemove": "Couldn't remove it from Watch later", "clearSearch": "Couldn't clear the search results", diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json index ec6f0092..80d8a9b8 100644 --- a/frontend/src/i18n/locales/hu/card.json +++ b/frontend/src/i18n/locales/hu/card.json @@ -8,7 +8,7 @@ "saveForLater": "Mentés későbbre", "unhide": "Megjelenítés", "hide": "Elrejtés", - "resetState": "Megtekintés visszaállítása", + "clearProgress": "Megtekintés visszaállítása", "thisChannel": "Ez a csatorna", "continueTitle": "Folytatás ott, ahol abbahagytad", "continue": "Folytatás", diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index d09b4406..299bf3ca 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -96,7 +96,7 @@ "prefs": "Nem sikerült menteni a beállításaidat", "deleteAccount": "Nem sikerült törölni a fiókodat", "videoState": "Nem sikerült módosítani a videó állapotát", - "videoReset": "Nem sikerült alaphelyzetbe állítani a videót", + "videoProgress": "Nem sikerült visszaállítani a videó megtekintését", "watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához", "watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából", "clearSearch": "Nem sikerült törölni a keresési találatokat", diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 53625713..d6ba1f82 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -29,10 +29,10 @@ export interface Video { watch_url: string; } -/** Whether a video has anything its reset would clear: pristine = never opened, the default status - * and no resume position. The card's reset button and the card menu's reset both ask this. */ -export function isResettable(v: Pick): boolean { - return v.status !== "new" || v.position_seconds > 0; +/** Whether a video has a resume point, the one thing "Clear watch progress" clears. Its status is no + * part of it: a hidden video stays hidden, a watched one watched. */ +export function hasResumePoint(v: Pick): boolean { + return v.position_seconds > 0; } /** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with @@ -290,8 +290,8 @@ export const feedApi = { { method: "POST", body: JSON.stringify({ status }) }, { idempotent: true }, ), - clearState: (id: string) => - req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }), + clearProgress: (id: string) => + req(`/api/videos/${id}/progress`, { method: "DELETE" }, { idempotent: true }), // Returns the position the server actually STORED — which is not always what we sent: a // trivially-early or near-finished checkpoint is cleared to 0 (see set_video_progress). Callers // that mirror the position into a cache must write this answer, never their own reading, so the diff --git a/frontend/src/lib/menuGlyphs.contract.test.ts b/frontend/src/lib/menuGlyphs.contract.test.ts index 8476dd96..33fbaed9 100644 --- a/frontend/src/lib/menuGlyphs.contract.test.ts +++ b/frontend/src/lib/menuGlyphs.contract.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; // Redownload. Each row is a `label: t("…")` followed by its `icon`. const ROW_GLYPHS: Record = { "card.restartTitle": "RotateCcw", - "card.resetState": "Eraser", + "card.clearProgress": "Eraser", "files.actions.redownload": "RefreshCw", }; diff --git a/frontend/src/modules/feed/Feed.reset.dom.test.tsx b/frontend/src/modules/feed/Feed.clearProgress.dom.test.tsx similarity index 72% rename from frontend/src/modules/feed/Feed.reset.dom.test.tsx rename to frontend/src/modules/feed/Feed.clearProgress.dom.test.tsx index a506acc1..bea48773 100644 --- a/frontend/src/modules/feed/Feed.reset.dom.test.tsx +++ b/frontend/src/modules/feed/Feed.clearProgress.dom.test.tsx @@ -18,16 +18,16 @@ vi.mock("../../features/wizard/wizardContext", () => ({ vi.mock("./VirtualFeed", () => ({ default: ({ items, - onResetState, + onClearProgress, }: { items: { id: string; position_seconds: number }[]; - onResetState: (id: string) => void; + onClearProgress: (id: string) => void; }) => (
    {items.map((v) => (
  1. {v.id}@{v.position_seconds} - +
  2. ))}
@@ -81,16 +81,16 @@ const halfWatched = { watch_url: "https://www.youtube.com/watch?v=v1", } as unknown as Video; -function mount(clear: () => Promise) { - vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never); +function mount(clear: () => Promise, video = halfWatched, filters = FILTERS) { + vi.spyOn(api, "feed").mockResolvedValue({ items: [video], next_cursor: null } as never); vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never); - vi.spyOn(api, "clearState").mockImplementation(clear as never); + vi.spyOn(api, "clearProgress").mockImplementation(clear as never); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); render( {}} view="cards" setView={() => {}} @@ -104,29 +104,41 @@ function mount(clear: () => Promise) { return qc; } -describe("Feed — resetting a half-watched video", () => { - it("clears its resume position at once, not only its status", async () => { +describe("Feed — clearing a half-watched video's progress", () => { + it("leaves its status alone: a hidden video stays in the Hidden view", async () => { + mount( + () => Promise.resolve({ ok: true }), + { ...halfWatched, status: "hidden" }, + { ...FILTERS, show: "hidden" }, + ); + fireEvent.click(await screen.findByText("clear v1")); + await screen.findByText("v1@0"); + await act(() => new Promise((r) => setTimeout(r, 20))); + expect(screen.getByText("v1@0")).toBeTruthy(); + }); + + it("clears its resume position at once", async () => { // Never settles: what the card shows before the server answers is the optimistic layer alone. mount(() => new Promise(() => {})); await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy()); - fireEvent.click(screen.getByText("reset v1")); + fireEvent.click(screen.getByText("clear v1")); await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy()); }); it("shows the position a replay after it saved, so a reopen resumes there", async () => { - // Reset, played to 40 s and closed: the player writes what the server stored into the cache. - // A reset mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint + // Cleared, played to 40 s and closed: the player writes what the server stored into the cache. + // A mask over the cache kept showing 0, a reopen resumed at 0, and its first checkpoint // wrote over the 40. const qc = mount(() => Promise.resolve({ ok: true })); - fireEvent.click(await screen.findByText("reset v1")); + fireEvent.click(await screen.findByText("clear v1")); await screen.findByText("v1@0"); act(() => patchCachedProgress(qc, new Map([["v1", 40]]))); await waitFor(() => expect(screen.getByText("v1@40")).toBeTruthy()); }); - it("keeps the position at 0 through a refetch that set out before the reset", async () => { - // That refetch read the row the reset deletes: its answer put the old position back under a - // card the reset had made new, and a reopen resumed there. + it("keeps the position at 0 through a refetch that set out before the clear", async () => { + // That refetch read the row before the clear: its answer put the old position back under a + // card the clear had reset, and a reopen resumed there. let answer: (v: unknown) => void = () => {}; const qc = mount(() => new Promise(() => {})); await screen.findByText("v1@95"); @@ -134,16 +146,16 @@ describe("Feed — resetting a half-watched video", () => { act(() => void qc.invalidateQueries({ queryKey: qk.feed() })); await waitFor(() => expect(api.feed).toHaveBeenCalledTimes(2)); await act(() => new Promise((r) => setTimeout(r, 20))); - fireEvent.click(screen.getByText("reset v1")); + fireEvent.click(screen.getByText("clear v1")); await screen.findByText("v1@0"); await act(async () => answer({ items: [halfWatched], next_cursor: null })); await act(() => new Promise((r) => setTimeout(r, 20))); expect(screen.getByText("v1@0")).toBeTruthy(); }); - it("puts the position back when the server refuses the reset", async () => { + it("puts the position back when the server refuses the clear", async () => { mount(() => Promise.reject(new Error("down"))); - fireEvent.click(await screen.findByText("reset v1")); + fireEvent.click(await screen.findByText("clear v1")); await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy()); }); }); diff --git a/frontend/src/modules/feed/Feed.tsx b/frontend/src/modules/feed/Feed.tsx index 1072a9b2..53ab5d8c 100644 --- a/frontend/src/modules/feed/Feed.tsx +++ b/frontend/src/modules/feed/Feed.tsx @@ -37,7 +37,7 @@ import { import { notifyActionError } from "../../lib/actionErrors"; import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice"; import { - resetVideoState, + clearVideoProgress, writeVideoState, writeWatchLater, } from "../../features/video/videoWrites"; @@ -210,9 +210,9 @@ export default function Feed({ const writeSeq = useRef(0); const answered = useRef(new Set()); const answeredAtRefetch = useRef>(new Set()); - // A reset's position 0 lives in the cached lists (see onResetState), where a refetch that set out - // before the server took the reset puts the old position back: written again on its landing. - const resets = useRef(new Map()); + // A cleared position's 0 lives in the cached lists (see onClearProgress), where a refetch that set + // out before the server took the clear puts the old position back: written again on its landing. + const clears = useRef(new Map()); // The open player: which video and where to start (null = resume from saved position). `alone`: // opened from an address naming a video this list has not loaded, so it plays without the queue. const [activeVideo, setActiveVideo] = useState<{ @@ -237,8 +237,8 @@ export default function Feed({ }); const openVideo = useCallback((video: Video, startAt: number | null = null) => { playerSession.current.open = true; - // Its player writes the next position: a reset no longer speaks for it. - resets.current.delete(video.id); + // Its player writes the next position: a clear no longer speaks for it. + clears.current.delete(video.id); setActiveVideo({ video, startAt }); }, []); // Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect @@ -356,8 +356,8 @@ export default function Feed({ setSavedOverrides((o) => unconfirmed(o, known)); for (const seq of known) answered.current.delete(seq); const zero = new Map(); - for (const [id, seq] of resets.current) { - if (known.has(seq)) resets.current.delete(id); + for (const [id, seq] of clears.current) { + if (known.has(seq)) clears.current.delete(id); else zero.set(id, 0); } patchCachedProgress(qc, zero); @@ -429,30 +429,26 @@ export default function Feed({ [qc, trackSessionWrite], ); - const onResetState = useCallback( + const onClearProgress = useCallback( (id: string) => { - // Reset to pristine (drop the whole VideoState row, incl. resume position) — the - // un-watch toggle can't clear an in-progress position, this can. + // The resume point alone: the status stays, so no status override. const seq = ++writeSeq.current; - setOverrides((o) => ({ ...o, [id]: { value: "new", seq } })); // The position goes into the cached lists, where the player's own closing patch writes the // next one: as a mask over them it outlived a replay, so a reopen resumed at 0 and its first // checkpoint wrote over the progress just made. const before = loadedRef.current.find((v) => v.id === id)?.position_seconds; patchCachedProgress(qc, new Map([[id, 0]])); - resets.current.set(id, seq); - resetVideoState(qc, id) - // Stale-mark only (see setState above): the optimistic override already reset the card. + clears.current.set(id, seq); + clearVideoProgress(qc, id) + // Stale-mark only (see setState above): the patch above already cleared the card. .then(() => { answered.current.add(seq); - setOverrides((o) => withAnswer(o, id, seq)); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); }) .catch((e: unknown) => { - notifyActionError(e, msg("errors.writeFailed.videoReset")); - setOverrides((o) => withoutWrite(o, id, seq)); - if (resets.current.get(id) !== seq) return; - resets.current.delete(id); + notifyActionError(e, msg("errors.writeFailed.videoProgress")); + if (clears.current.get(id) !== seq) return; + clears.current.delete(id); if (before !== undefined) patchCachedProgress(qc, new Map([[id, before]])); }); }, @@ -666,7 +662,7 @@ export default function Feed({ view={view} onState={onState} onToggleSave={onToggleSave} - onResetState={onResetState} + onClearProgress={onClearProgress} onOpen={openVideo} hasNextPage={false} isFetching={false} @@ -1004,7 +1000,7 @@ export default function Feed({ view={view} onState={onState} onToggleSave={onToggleSave} - onResetState={onResetState} + onClearProgress={onClearProgress} onOpen={openVideo} // A channel page's view is the channel already: no row there to narrow it to one. channelFilter={ diff --git a/frontend/src/modules/feed/VirtualFeed.tsx b/frontend/src/modules/feed/VirtualFeed.tsx index 42a4ae38..c50b07c1 100644 --- a/frontend/src/modules/feed/VirtualFeed.tsx +++ b/frontend/src/modules/feed/VirtualFeed.tsx @@ -27,7 +27,7 @@ export interface VirtualFeedProps { view: FeedView; onState: (id: string, status: VideoStatus) => void; onToggleSave: (id: string, saved: boolean) => void; - onResetState: (id: string) => void; + onClearProgress: (id: string) => void; onOpen: (v: Video, startAt?: number | null) => void; /** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */ channelFilter?: VideoActionHandlers["channelFilter"]; @@ -44,7 +44,7 @@ export default function VirtualFeed({ view, onState, onToggleSave, - onResetState, + onClearProgress, onOpen, channelFilter, hasNextPage, @@ -92,7 +92,7 @@ export default function VirtualFeed({ /> ); diff --git a/frontend/src/modules/home/Home.dom.test.tsx b/frontend/src/modules/home/Home.dom.test.tsx index ef674864..8f4fbf2b 100644 --- a/frontend/src/modules/home/Home.dom.test.tsx +++ b/frontend/src/modules/home/Home.dom.test.tsx @@ -46,14 +46,14 @@ vi.mock("react-i18next", async (importOriginal) => { const discover = vi.fn(); const discoverPick = vi.fn(); const videoRow = vi.fn(); -const clearState = vi.fn((_id: string) => Promise.resolve()); +const clearProgress = vi.fn((_id: string) => Promise.resolve()); vi.mock("../../lib/api", () => ({ api: { discover: (...a: unknown[]) => discover(...a), videoRow: (...a: unknown[]) => videoRow(...a), discoverPick: (...a: unknown[]) => discoverPick(...a), setState: () => Promise.resolve(), - clearState: (id: string) => clearState(id), + clearProgress: (id: string) => clearProgress(id), watchLaterAdd: () => Promise.resolve(), watchLaterRemove: () => Promise.resolve(), downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it @@ -816,11 +816,11 @@ describe("Home", () => { fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!); const reset = screen .getAllByRole("menuitem", { hidden: true }) - .find((b) => b.textContent === "card.resetState"); + .find((b) => b.textContent === "card.clearProgress"); expect(reset).toBeTruthy(); const asked = discover.mock.calls.length; fireEvent.click(reset!); - await waitFor(() => expect(clearState).toHaveBeenCalledWith("v1")); + await waitFor(() => expect(clearProgress).toHaveBeenCalledWith("v1")); await waitFor(() => expect(discover.mock.calls.length).toBeGreaterThan(asked)); }); diff --git a/frontend/src/modules/home/Home.tsx b/frontend/src/modules/home/Home.tsx index b1652c42..c2e80e61 100644 --- a/frontend/src/modules/home/Home.tsx +++ b/frontend/src/modules/home/Home.tsx @@ -53,7 +53,7 @@ import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard"; import { useVideoActions } from "../../features/video/useVideoActions"; import { - resetVideoState, + clearVideoProgress, writeVideoState, writeWatchLater, } from "../../features/video/videoWrites"; @@ -161,14 +161,14 @@ function useHomeCardMenu({ // A video tile's own action list, the same one the feed's menu shows; the rail keeps its // Play row (it resolves `next`) and its shelf rows around it. const qc = useQueryClient(); - // Back to fresh: what the rails answer changes with it (a reset video leaves Continue watching), + // What the rails answer changes with it (a video with no resume point leaves Continue watching), // so Home asks again, as its marks do. - const onResetState = (id: string) => { - void resetVideoState(qc, id) + const onClearProgress = (id: string) => { + void clearVideoProgress(qc, id) .then(() => qc.invalidateQueries({ queryKey: qk.discover() })) - .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoReset"))); + .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoProgress"))); }; - const videoActions = useVideoActions({ onState, onToggleSave, onResetState }, menu.open); + const videoActions = useVideoActions({ onState, onToggleSave, onClearProgress }, menu.open); // A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does). const plexActions = usePlexCardActions( { diff --git a/frontend/src/modules/playlists/Playlists.dom.test.tsx b/frontend/src/modules/playlists/Playlists.dom.test.tsx index ed9d4732..746b531b 100644 --- a/frontend/src/modules/playlists/Playlists.dom.test.tsx +++ b/frontend/src/modules/playlists/Playlists.dom.test.tsx @@ -570,14 +570,14 @@ async function rowMenu(title: string, item: string) { } describe("Playlists — a row's watch state", () => { - it("resets a started video's view from its menu", async () => { - vi.spyOn(api, "clearState").mockResolvedValue(undefined as never); + it("clears a started video's progress from its menu", async () => { + vi.spyOn(api, "clearProgress").mockResolvedValue(undefined as never); mount({ 1: [vid("A", 0), vid("B", 300)] }); - await rowMenu("Video B", "card.resetState"); - await waitFor(() => expect(api.clearState).toHaveBeenCalledWith("B")); + await rowMenu("Video B", "card.clearProgress"); + await waitFor(() => expect(api.clearProgress).toHaveBeenCalledWith("B")); }); - it("offers no reset on a video never started", async () => { + it("offers no clearing on a video never started", async () => { mount({ 1: [vid("A", 0)] }); const row = (await screen.findByRole("button", { name: "card.play — Video A" })).closest("tr")!; fireEvent.contextMenu(row); @@ -587,7 +587,7 @@ describe("Playlists — a row's watch state", () => { expect( screen .getAllByRole("menuitem", { hidden: true }) - .some((b) => b.textContent === "card.resetState"), + .some((b) => b.textContent === "card.clearProgress"), ).toBe(false); }); }); diff --git a/frontend/src/modules/playlists/Playlists.tsx b/frontend/src/modules/playlists/Playlists.tsx index fb4f4c9f..133aab48 100644 --- a/frontend/src/modules/playlists/Playlists.tsx +++ b/frontend/src/modules/playlists/Playlists.tsx @@ -3,12 +3,12 @@ import { useLatest, useStableCallback } from "../../lib/useLatest"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; -import { isResettable } from "../../lib/api/feed"; +import { hasResumePoint } from "../../lib/api/feed"; import { addPlaylistItem, movePlaylistItem, removePlaylistItem, - resetVideoState, + clearVideoProgress, writeVideoState, } from "../../features/video/videoWrites"; import { @@ -685,14 +685,14 @@ function PlaylistDetail() { return write; } - // Back to fresh (the video's state row and resume point both go), from the row's menu. - function resetRow(id: string) { - resetVideoState(qc, id) + // The video's resume point goes (its status stays), from the row's menu. + function clearRowProgress(id: string) { + clearVideoProgress(qc, id) .then(() => { qc.invalidateQueries({ queryKey: qk.playlist() }); qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }); }) - .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoReset"))); + .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoProgress"))); } // Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure @@ -744,13 +744,13 @@ function PlaylistDetail() { onSelect: () => openPlayer(ctxRow.id), }, ...comfortPin.item(pinVideo(ctxRow.id), raw(ctxRow.title ?? "")), - ...(isResettable(ctxRow) + ...(hasResumePoint(ctxRow) ? [ { - id: "reset", - label: t("card.resetState"), + id: "clearProgress", + label: t("card.clearProgress"), icon: , - onSelect: () => resetRow(ctxRow.id), + onSelect: () => clearRowProgress(ctxRow.id), }, ] : []), From 7f8d7cbe2038dcfc1be09c70510ae3a00c0b9543 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 1 Nov 2026 19:01:45 +0100 Subject: [PATCH 13/13] chore(release): 0.115.0 --- CHANGELOG.md | 15 +++++++++++++++ VERSION | 2 +- frontend/src/content/release-notes/en.json | 15 +++++++++++++++ frontend/src/content/release-notes/hu.json | 15 +++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85cd6411..76a1e8f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.115.0 — 2026-11-01 + +### New + +- Clear watch progress is on every YouTube video's menu, on Home, in playlists and in the player's related videos. It forgets where you left a started video and changes nothing else. +- Channels have an Added column, and an About card opens only when the pointer rests on it. +- In Files, a press anywhere on a row plays the file. +- The feed's toolbar pills are named by a glyph, with Sort and View on the filters' row. + +### Fixed + +- In a play queue, Previous and Next pass over a video that can't play, in the direction you were going, and say why when only such videos are left. +- A Facebook download titled only “Facebook” now takes the post's text as its name. +- The player's big view keeps its way out in sight on a tablet, and Tab no longer lands on controls hidden under it. + ## 0.114.3 — 2026-11-01 ### Fixed diff --git a/VERSION b/VERSION index c672d9c2..bdc80994 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.114.3 +0.115.0 diff --git a/frontend/src/content/release-notes/en.json b/frontend/src/content/release-notes/en.json index a1cf90ee..971cca82 100644 --- a/frontend/src/content/release-notes/en.json +++ b/frontend/src/content/release-notes/en.json @@ -1,4 +1,19 @@ [ + { + "version": "0.115.0", + "date": "2026-11-01", + "new": [ + "Clear watch progress is on every YouTube video's menu, on Home, in playlists and in the player's related videos. It forgets where you left a started video and changes nothing else.", + "Channels have an Added column, and an About card opens only when the pointer rests on it.", + "In Files, a press anywhere on a row plays the file.", + "The feed's toolbar pills are named by a glyph, with Sort and View on the filters' row." + ], + "fixed": [ + "In a play queue, Previous and Next pass over a video that can't play, in the direction you were going, and say why when only such videos are left.", + "A Facebook download titled only “Facebook” now takes the post's text as its name.", + "The player's big view keeps its way out in sight on a tablet, and Tab no longer lands on controls hidden under it." + ] + }, { "version": "0.114.3", "date": "2026-11-01", diff --git a/frontend/src/content/release-notes/hu.json b/frontend/src/content/release-notes/hu.json index ee918e94..efb06aca 100644 --- a/frontend/src/content/release-notes/hu.json +++ b/frontend/src/content/release-notes/hu.json @@ -1,4 +1,19 @@ [ + { + "version": "0.115.0", + "date": "2026-11-01", + "new": [ + "A Megtekintés visszaállítása minden YouTube-videó menüjében elérhető: a Kezdőlapon, a listákban és a lejátszó kapcsolódó videóinál. Egy megkezdett videónál törli, hol tartottál, mást nem változtat.", + "A csatornáknál van Hozzáadva oszlop, és a Leírás kártya csak akkor nyílik meg, ha a mutató megáll rajta.", + "A Fájloknál a sorban bárhová kattintva elindul a fájl.", + "A Feed eszköztárának gombjait ikon jelöli, a Rendezés és a Nézet a szűrők sorába került." + ], + "fixed": [ + "A lejátszási sorban az Előző és a Következő átlépi a nem lejátszható videót abban az irányban, amerre haladtál, és megmondja az okát, ha már csak ilyenek maradtak.", + "A csak „Facebook” címmel letöltött Facebook-videó most a bejegyzés szövegét kapja névnek.", + "A lejátszó nagy nézetében tableten is látszik a kilépés, és a Tab már nem kerül a takart gombokra." + ] + }, { "version": "0.114.3", "date": "2026-11-01",