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:
@@ -163,6 +163,15 @@ describe("RelatedVideos", () => {
|
|||||||
await waitFor(() => expect(titles()).toEqual(["Video A"]));
|
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 () => {
|
it("reads further with the cursor the last page gave", async () => {
|
||||||
const asked = vi
|
const asked = vi
|
||||||
.spyOn(api, "related")
|
.spyOn(api, "related")
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import Tooltip from "../../components/ui/Tooltip";
|
|||||||
import VideoCard from "../video/VideoCard";
|
import VideoCard from "../video/VideoCard";
|
||||||
import { VideoMenu } from "../video/VideoMenu";
|
import { VideoMenu } from "../video/VideoMenu";
|
||||||
import { announceVideoState } from "../video/stateNotice";
|
import { announceVideoState } from "../video/stateNotice";
|
||||||
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
|
import { resetVideoState, writeVideoState, writeWatchLater } from "../video/videoWrites";
|
||||||
import { msg } from "../../lib/notifications";
|
import { msg } from "../../lib/notifications";
|
||||||
|
|
||||||
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
|
/** 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 onToggleSave = (id: string, saved: boolean) => {
|
||||||
const seq = begin(id, { saved });
|
const seq = begin(id, { saved });
|
||||||
const write = writeWatchLater(qc, id, saved);
|
const write = writeWatchLater(qc, id, saved);
|
||||||
@@ -231,7 +246,7 @@ export function RelatedVideos({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onOpen: open }} />
|
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen: open }} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function downloadRow(state: string | undefined) {
|
|||||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
);
|
);
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn() }, true),
|
() => useVideoActions({ onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() }, true),
|
||||||
{ wrapper },
|
{ wrapper },
|
||||||
);
|
);
|
||||||
return () => result.current.items(video, null).find((i) => i.id === "download")!;
|
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(),
|
onState: vi.fn(),
|
||||||
onToggleSave: vi.fn(),
|
onToggleSave: vi.fn(),
|
||||||
|
onResetState: vi.fn(),
|
||||||
channelFilter: ids ? { ids, set } : undefined,
|
channelFilter: ids ? { ids, set } : undefined,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ import { raw } from "../../lib/notifications";
|
|||||||
export interface VideoActionHandlers {
|
export interface VideoActionHandlers {
|
||||||
onState: (id: string, status: VideoStatus) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
/** Omitted where a surface cannot reset a video's state (Home's shelves). */
|
/** Back to fresh: its state row and resume point both go. */
|
||||||
onResetState?: (id: string) => void;
|
onResetState: (id: string) => void;
|
||||||
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
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
|
/** 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,
|
separatorBefore: true,
|
||||||
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
||||||
},
|
},
|
||||||
...(onResetState && isResettable(v)
|
...(isResettable(v)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "reset",
|
id: "reset",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"saveForLater": "Save for later",
|
"saveForLater": "Save for later",
|
||||||
"unhide": "Unhide",
|
"unhide": "Unhide",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"resetState": "Reset — clear watch progress/status",
|
"resetState": "Reset view",
|
||||||
"thisChannel": "This channel",
|
"thisChannel": "This channel",
|
||||||
"continueTitle": "Continue where you left off",
|
"continueTitle": "Continue where you left off",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"saveForLater": "Mentés későbbre",
|
"saveForLater": "Mentés későbbre",
|
||||||
"unhide": "Megjelenítés",
|
"unhide": "Megjelenítés",
|
||||||
"hide": "Elrejté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",
|
"thisChannel": "Ez a csatorna",
|
||||||
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
||||||
"continue": "Folytatás",
|
"continue": "Folytatás",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 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
|
// 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.
|
// 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 discover = vi.fn();
|
||||||
const discoverPick = vi.fn();
|
const discoverPick = vi.fn();
|
||||||
const videoRow = vi.fn();
|
const videoRow = vi.fn();
|
||||||
|
const clearState = vi.fn((_id: string) => Promise.resolve());
|
||||||
vi.mock("../../lib/api", () => ({
|
vi.mock("../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
discover: (...a: unknown[]) => discover(...a),
|
discover: (...a: unknown[]) => discover(...a),
|
||||||
videoRow: (...a: unknown[]) => videoRow(...a),
|
videoRow: (...a: unknown[]) => videoRow(...a),
|
||||||
discoverPick: (...a: unknown[]) => discoverPick(...a),
|
discoverPick: (...a: unknown[]) => discoverPick(...a),
|
||||||
setState: () => Promise.resolve(),
|
setState: () => Promise.resolve(),
|
||||||
|
clearState: (id: string) => clearState(id),
|
||||||
watchLaterAdd: () => Promise.resolve(),
|
watchLaterAdd: () => Promise.resolve(),
|
||||||
watchLaterRemove: () => Promise.resolve(),
|
watchLaterRemove: () => Promise.resolve(),
|
||||||
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
|
downloadIndex: () => Promise.resolve({}), // the cards' download buttons read it
|
||||||
@@ -803,6 +805,25 @@ describe("Home", () => {
|
|||||||
expect(setPage).toHaveBeenCalledWith("plex");
|
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.
|
// 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 () => {
|
it("a started film's menu starts it over: the player opens from the start", async () => {
|
||||||
const movie = {
|
const movie = {
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
|||||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||||
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
|
import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
|
||||||
import { useVideoActions } from "../../features/video/useVideoActions";
|
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 { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||||
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
|
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||||
import { usePlaylistsActions } from "../playlists";
|
import { usePlaylistsActions } from "../playlists";
|
||||||
@@ -156,7 +160,15 @@ function useHomeCardMenu({
|
|||||||
const pin = useComfortPin(menu.open);
|
const pin = useComfortPin(menu.open);
|
||||||
// A video tile's own action list, the same one the feed's menu shows; the rail keeps its
|
// 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.
|
// 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).
|
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
|
||||||
const plexActions = usePlexCardActions(
|
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", () => {
|
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`
|
// 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
|
// object for a deep-equal answer, and the page's re-seed effect is keyed on that object — so the
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { useLatest, useStableCallback } from "../../lib/useLatest";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
import { isResettable } from "../../lib/api/feed";
|
||||||
import {
|
import {
|
||||||
addPlaylistItem,
|
addPlaylistItem,
|
||||||
movePlaylistItem,
|
movePlaylistItem,
|
||||||
removePlaylistItem,
|
removePlaylistItem,
|
||||||
|
resetVideoState,
|
||||||
writeVideoState,
|
writeVideoState,
|
||||||
} from "../../features/video/videoWrites";
|
} from "../../features/video/videoWrites";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
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 { YouTubeIcon } from "../../lib/brandIcons";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -683,6 +685,16 @@ function PlaylistDetail() {
|
|||||||
return write;
|
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
|
// 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.
|
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
|
||||||
const closePlayer = () => setPlaying(null);
|
const closePlayer = () => setPlaying(null);
|
||||||
@@ -732,6 +744,16 @@ function PlaylistDetail() {
|
|||||||
onSelect: () => openPlayer(ctxRow.id),
|
onSelect: () => openPlayer(ctxRow.id),
|
||||||
},
|
},
|
||||||
...comfortPin.item(pinVideo(ctxRow.id), raw(ctxRow.title ?? "")),
|
...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) => ({
|
...copy(ctxRow.title, ctxRow.watch_url).map((it, i) => ({
|
||||||
...it,
|
...it,
|
||||||
separatorBefore: i === 0,
|
separatorBefore: i === 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user