feat(video): Reset view on every YouTube video menu

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".
This commit is contained in:
2026-11-01 01:20:20 +01:00
parent 15a3603513
commit e06c0688d9
11 changed files with 115 additions and 12 deletions
@@ -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.
@@ -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 = {
+14 -2
View File
@@ -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(
{
@@ -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
+23 -1
View File
@@ -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: <Eraser className="w-4 h-4" />,
onSelect: () => resetRow(ctxRow.id),
},
]
: []),
...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({
...it,
separatorBefore: i === 0,