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,