fix: review round 2 — Clear watch progress clears only the resume point, big view focus by the way it arrived, dialog name, reason tooltip, backfill locks
This commit is contained in:
@@ -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(
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A, vid("rel2", 120)]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
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(
|
||||
<PlayerModal
|
||||
video={A}
|
||||
queue={[A]}
|
||||
queue={[A, ...others]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("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" })) {
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const bigExitRef = useRef<HTMLButtonElement>(null);
|
||||
const focusBeforeBig = useRef<HTMLElement | null>(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. */}
|
||||
<span id={titleId} hidden>
|
||||
{heading}
|
||||
</span>
|
||||
{/* 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}
|
||||
/>
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
<span className="h-4 w-px shrink-0 bg-white/20" />
|
||||
</>
|
||||
)}
|
||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||
@@ -1383,6 +1392,9 @@ export default function PlayerModal({
|
||||
<div
|
||||
ref={cardPanelRef}
|
||||
inert={bigView}
|
||||
onFocus={(e) => {
|
||||
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. */}
|
||||
<h2 id={titleId} className="sr-only">
|
||||
{heading}
|
||||
</h2>
|
||||
<h2 className="sr-only">{heading}</h2>
|
||||
{closeButton}
|
||||
<IconButton
|
||||
ref={panelToggleRef}
|
||||
@@ -1408,24 +1417,28 @@ export default function PlayerModal({
|
||||
{hasQueue && (
|
||||
<>
|
||||
<span className="flex-1" />
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goPrev}
|
||||
{...unavailable(!canPrev, prevReason)}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←${!canPrev && prevReason ? ` · ${prevReason}` : ""}`}
|
||||
>
|
||||
<SkipBack />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goNext}
|
||||
{...unavailable(!canNext, nextReason)}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→${!canNext && nextReason ? ` · ${nextReason}` : ""}`}
|
||||
>
|
||||
<SkipForward />
|
||||
</IconButton>
|
||||
<ReasonTooltip reason={!canPrev ? prevReason : undefined} side="left">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goPrev}
|
||||
{...unavailable(!canPrev, prevReason)}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
>
|
||||
<SkipBack />
|
||||
</IconButton>
|
||||
</ReasonTooltip>
|
||||
<ReasonTooltip reason={!canNext ? nextReason : undefined} side="left">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={goNext}
|
||||
{...unavailable(!canNext, nextReason)}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
>
|
||||
<SkipForward />
|
||||
</IconButton>
|
||||
</ReasonTooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -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). */}
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{heading}
|
||||
</h2>
|
||||
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">{heading}</h2>
|
||||
{!wide && (
|
||||
<IconButton
|
||||
ref={setDescAnchor}
|
||||
@@ -1512,7 +1523,7 @@ export default function PlayerModal({
|
||||
videoId={active.id}
|
||||
onPlay={playRelated}
|
||||
onWrite={onWrite}
|
||||
onReset={forgetPosition}
|
||||
onCleared={clearPosition}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
@@ -1769,7 +1780,7 @@ export default function PlayerModal({
|
||||
videoId={active.id}
|
||||
onPlay={playRelated}
|
||||
onWrite={onWrite}
|
||||
onReset={forgetPosition}
|
||||
onCleared={clearPosition}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
|
||||
@@ -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<unknown>) => 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({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen: open }} />
|
||||
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onClearProgress, onOpen: open }} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip hint={reason ?? ""} always={!!reason} side={side} stable>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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) => (
|
||||
<IconButton
|
||||
key={c.key}
|
||||
variant={docked ? "overlay" : undefined}
|
||||
onClick={c.onClick}
|
||||
{...unavailable(c.disabled, c.reason)}
|
||||
lit={c.lit}
|
||||
label={c.name}
|
||||
title={c.reason ? `${c.title} · ${c.reason}` : c.title}
|
||||
>
|
||||
{c.glyph}
|
||||
</IconButton>
|
||||
<ReasonTooltip key={c.key} reason={c.reason}>
|
||||
<IconButton
|
||||
variant={docked ? "overlay" : undefined}
|
||||
onClick={c.onClick}
|
||||
{...unavailable(c.disabled, c.reason)}
|
||||
lit={c.lit}
|
||||
label={c.name}
|
||||
title={c.title}
|
||||
>
|
||||
{c.glyph}
|
||||
</IconButton>
|
||||
</ReasonTooltip>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ import RemoteImage from "../../components/ui/RemoteImage";
|
||||
type More = { props: Record<string, unknown>; 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. */
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<Feed v={v} handlers={handlers} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -39,7 +39,8 @@ function downloadRow(state: string | undefined) {
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -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: <Eraser className={icon} />,
|
||||
onSelect: () => onResetState(v.id),
|
||||
onSelect: () => onClearProgress(v.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user