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,