fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read: a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the server's word. The live search lays them over its rows too, not only the related list. Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go through features/video/videoWrites and mark the video; the contract test covers those writes and catches a write that is named without being called on the spot. The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's menu, a notice's Undo); Restart on the related row after a description link goes back to it. The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last two open-coded reads. Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both places render from one list of controls, so the docked tooltips name the mode too. The English cap message and admin hint quote "Show more"; the hint says refunds are limited.
This commit is contained in:
+9
-3
@@ -225,7 +225,9 @@ Also in force:
|
||||
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
|
||||
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
|
||||
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
|
||||
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` (or a bare
|
||||
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A mode cycle (the player's
|
||||
auto-advance and loop) is no two-state switch: `lit` gives it the same look when it is not off, without
|
||||
`aria-pressed`, and its name says the mode. A raw `<button>` (or a bare
|
||||
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
|
||||
it. [WCAG 4.1.2]
|
||||
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
|
||||
@@ -252,7 +254,10 @@ Also in force:
|
||||
[Nielsen #1]
|
||||
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
||||
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed
|
||||
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
|
||||
download's Retry had worn the Play glyph, which promised a video. The skip arrows (⏮ ⏭) are Previous /
|
||||
Next; the auto-advance mode beside them wears list glyphs (`ListEnd` next, `ListStart` previous,
|
||||
`Shuffle` random; off is `ListEnd`, not lit) — a mode in the skip arrows put two identical ⏭ on the
|
||||
panel's glyph-only row. [Nielsen #4]
|
||||
|
||||
## Surfaces and page chrome — _Decided_ (batch B)
|
||||
|
||||
@@ -560,7 +565,7 @@ of a full-width panel and nothing on the right.
|
||||
"Play from the beginning", and its picture carries the cards' progress bar.
|
||||
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel
|
||||
(its transport row: glyphs only — Previous, the count, Next, auto-advance, loop on one line, each named in
|
||||
its tooltip — or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
|
||||
its tooltip, the modes with their current one — or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is
|
||||
an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row
|
||||
under the video (stacked). Wide, the panel shows the description outright, clamped to three
|
||||
lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem
|
||||
@@ -814,3 +819,4 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
|
||||
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
|
||||
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
|
||||
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
|
||||
|
||||
@@ -52,6 +52,18 @@ describe("Button primitive", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a lit cycle paints the pressed look without announcing a toggle", () => {
|
||||
render(
|
||||
<Button variant="overlay" lit>
|
||||
Loop: All
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Loop: All" });
|
||||
expect(btn.hasAttribute("aria-pressed")).toBe(false);
|
||||
expect(btn.hasAttribute("data-lit")).toBe(true);
|
||||
expect(btn.className).toContain("data-lit:bg-accent");
|
||||
});
|
||||
|
||||
it("a toggle announces aria-pressed and paints the pressed state", () => {
|
||||
const { rerender } = render(
|
||||
<Button variant="outline" pressed={false}>
|
||||
|
||||
@@ -30,6 +30,9 @@ interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "
|
||||
loading?: boolean;
|
||||
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
|
||||
pressed?: boolean;
|
||||
/** A mode cycle that is not off: the pressed look without aria-pressed — its name says the mode (a
|
||||
* cycle is no two-state switch). */
|
||||
lit?: boolean;
|
||||
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
||||
"aria-pressed"?: never;
|
||||
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
|
||||
@@ -55,6 +58,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
label,
|
||||
loading = false,
|
||||
pressed,
|
||||
lit,
|
||||
round = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
@@ -84,9 +88,10 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
onBlur={onBlur}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
data-lit={lit || undefined}
|
||||
title={label}
|
||||
className={clsx(
|
||||
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
|
||||
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed, lit }),
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -59,6 +59,25 @@ describe("IconButton", () => {
|
||||
expect(screen.getByRole("button", { name: "Saved" }).getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// A mode cycle (auto-advance, loop) is no two-state switch: it looks on, its name says the mode.
|
||||
it("a lit cycle tints its glyph without announcing a toggle", () => {
|
||||
const { rerender } = render(
|
||||
<IconButton label="Auto: Next" lit>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Auto: Next" });
|
||||
expect(btn.hasAttribute("aria-pressed")).toBe(false);
|
||||
expect(btn.hasAttribute("data-lit")).toBe(true);
|
||||
expect(btn.className).toContain("data-lit:text-chosen");
|
||||
rerender(
|
||||
<IconButton label="Auto: Off" lit={false}>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
);
|
||||
expect(btn.hasAttribute("data-lit")).toBe(false);
|
||||
});
|
||||
|
||||
it("loading swaps the glyph for a spinner and swallows the click", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface IconButtonProps
|
||||
/** A toggle: sets aria-pressed and tints the glyph with the chosen colour (the accent; the media
|
||||
* amber on the player chrome). */
|
||||
pressed?: boolean;
|
||||
/** A mode cycle that is not off: the pressed tint without aria-pressed — its `label` says the mode
|
||||
* (a cycle is no two-state switch). */
|
||||
lit?: boolean;
|
||||
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
||||
"aria-pressed"?: never;
|
||||
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
|
||||
@@ -40,6 +43,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
pressed,
|
||||
lit,
|
||||
round = false,
|
||||
loading = false,
|
||||
noTitle = false,
|
||||
@@ -56,8 +60,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
busy={loading}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
data-lit={lit || undefined}
|
||||
title={noTitle ? undefined : label}
|
||||
className={clsx(iconButtonClass({ variant, size, round, pressed }), className)}
|
||||
className={clsx(iconButtonClass({ variant, size, round, pressed, lit }), className)}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : children}
|
||||
|
||||
@@ -60,6 +60,11 @@ const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
||||
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
|
||||
const PRESSED =
|
||||
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
|
||||
// A mode cycle that is not off (`lit`: auto-advance Next, loop One) looks pressed but is no toggle —
|
||||
// its name says the mode, and aria-pressed would announce a two-state switch whose name changes on
|
||||
// every press. Keyed to an attribute like PRESSED, so it outranks the variant's own colours.
|
||||
const LIT =
|
||||
"data-lit:bg-accent data-lit:text-accent-fg data-lit:border-accent data-lit:no-underline";
|
||||
|
||||
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
|
||||
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
|
||||
@@ -122,6 +127,7 @@ const ICON_VARIANTS: Record<IconButtonVariant, string> = {
|
||||
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
|
||||
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
|
||||
const ICON_PRESSED = "aria-pressed:text-chosen";
|
||||
const ICON_LIT = "data-lit:text-chosen";
|
||||
|
||||
const ICON_SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "size-7 [&>svg]:size-4",
|
||||
@@ -138,17 +144,20 @@ export function buttonClass({
|
||||
round = false,
|
||||
fullWidth = false,
|
||||
pressed,
|
||||
lit,
|
||||
}: {
|
||||
variant: ButtonVariant;
|
||||
size: ButtonSize;
|
||||
round?: boolean;
|
||||
fullWidth?: boolean;
|
||||
pressed?: boolean;
|
||||
lit?: boolean;
|
||||
}) {
|
||||
return clsx(
|
||||
BASE,
|
||||
BUTTON_VARIANTS[variant],
|
||||
pressed !== undefined && PRESSED,
|
||||
lit !== undefined && LIT,
|
||||
SIZES[size],
|
||||
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
||||
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites — on every variant.
|
||||
@@ -165,17 +174,20 @@ export function iconButtonClass({
|
||||
size,
|
||||
round = false,
|
||||
pressed,
|
||||
lit,
|
||||
}: {
|
||||
variant: IconButtonVariant;
|
||||
size: IconButtonSize;
|
||||
round?: boolean;
|
||||
pressed?: boolean;
|
||||
lit?: boolean;
|
||||
}) {
|
||||
return clsx(
|
||||
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-chosen disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
ICON_VARIANTS[variant],
|
||||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||
pressed !== undefined && ICON_PRESSED,
|
||||
lit !== undefined && ICON_LIT,
|
||||
ICON_SIZES[size],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { api, type Me, type Video } from "../../lib/api";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -104,7 +105,7 @@ const vid = (id: string, position_seconds: number): Video =>
|
||||
function renderModal(ui: ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), me);
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
return Object.assign(render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>), { qc });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -962,6 +963,40 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(started.length).toBe(before);
|
||||
});
|
||||
|
||||
// A description link put another video in the player: Restart on the related row brings the
|
||||
// related video back from 0 — the seek moved the linked one.
|
||||
it("wide: Restart on the related row after a description link goes back to it from 0", async () => {
|
||||
wideWindow(true);
|
||||
vi.spyOn(api, "videoDetail").mockResolvedValue({
|
||||
description: "More: https://www.youtube.com/watch?v=linkedVid01",
|
||||
} as Awaited<ReturnType<typeof api.videoDetail>>);
|
||||
vi.spyOn(api, "related").mockResolvedValue({
|
||||
items: [related("rel1"), related("rel2", 120)],
|
||||
has_more: false,
|
||||
next_cursor: null,
|
||||
});
|
||||
const A = vid("A", 40);
|
||||
renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||
);
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||
fireEvent.click(titleOf(await screen.findAllByRole("link", { name: "Video rel2" })));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("rel2"));
|
||||
act(() => playerEvents.onReady?.());
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.description" }));
|
||||
fireEvent.click((await screen.findAllByTitle("player.playInApp"))[0]!);
|
||||
expect(loaded[loaded.length - 1]?.videoId).toBe("linkedVid01");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "player.related.title" }));
|
||||
const list = await screen.findByTestId("player-related");
|
||||
const row = within(list)
|
||||
.getAllByRole("link", { name: "Video rel2" })[0]!
|
||||
.closest("[data-nav-index]")!;
|
||||
fireEvent.click(within(row as HTMLElement).getAllByTitle("card.restartTitle")[0]!);
|
||||
expect(loaded[loaded.length - 1]).toEqual({ videoId: "rel2", startSeconds: 0 });
|
||||
expect(seeks).not.toContain(0);
|
||||
});
|
||||
|
||||
// The toggle remembers each video's mark: a related one's did not overwrite the opened one's.
|
||||
it("wide: Back shows the opened video's own mark, not the one it was opened with", async () => {
|
||||
const onState = vi.fn();
|
||||
@@ -979,6 +1014,45 @@ describe("PlayerModal — the side panel on a wide window", () => {
|
||||
expect(screen.queryAllByTitle("player.markWatched")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A mark the session confirmed elsewhere while the video plays (the related row's own menu, a
|
||||
// notice's Undo) reaches the toggle; alone, its first click re-sent the same status.
|
||||
// A mark from before the player opened is older than the item it was opened with (a list read
|
||||
// since): the item's own status stands.
|
||||
it("goes by the opened item, not a mark made before it opened", async () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const A = vid("A", 0);
|
||||
wideWindow(true);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), me);
|
||||
await writeVideoState(qc, "A", "watched");
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await settle();
|
||||
expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0);
|
||||
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("follows the playing video's mark written elsewhere, and its own click after that", async () => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const onState = vi.fn();
|
||||
const A = vid("A", 0);
|
||||
wideWindow(true);
|
||||
const { qc } = renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={onState} />,
|
||||
);
|
||||
expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0);
|
||||
await act(async () => void (await writeVideoState(qc, "A", "watched")));
|
||||
fireEvent.click(await screen.findByTitle("player.watchedUnmark"));
|
||||
expect(onState.mock.calls.map((c) => c.slice(0, 2))).toEqual([["A", "new"]]);
|
||||
expect(screen.getAllByTitle("player.markWatched").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("wide: a queue step leaves the related video for the queue's next item", async () => {
|
||||
await playRel2(() => {}, [vid("A", 40), vid("B", 0)]);
|
||||
next();
|
||||
|
||||
@@ -56,6 +56,7 @@ import DownloadButton from "../downloads/DownloadButton";
|
||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
|
||||
import { api, type Video, type VideoStatus } from "../../lib/api";
|
||||
import { markedValue, useVideoMarks } from "../../lib/progressCache";
|
||||
import type { YTPlayer } from "../../lib/youtube";
|
||||
import {
|
||||
channelYouTubeUrl,
|
||||
@@ -543,19 +544,30 @@ export default function PlayerModal({
|
||||
});
|
||||
|
||||
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
|
||||
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
|
||||
// is ignored rather than displayed — that is the enforcement, and it has to be in code because
|
||||
// one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
|
||||
// after the commit that already advanced `active`, and near the end of a clip it auto-marks that
|
||||
// item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
|
||||
// item's, so the toggle falls back to `subject.status` and no ordering has to be argued about.
|
||||
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
|
||||
// onState(next, "new") for an item that was already new.
|
||||
const [marks, setMarks] = useState<Record<string, VideoStatus>>({});
|
||||
const watched = (marks[subject.id] ?? subject.status) === "watched";
|
||||
// (and server) via onState. Each mark is kept under the id it was MADE for, and that has to be in
|
||||
// code because one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING
|
||||
// item runs after the commit that already advanced `active`, and near the end of a clip it
|
||||
// auto-marks that item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that late write is
|
||||
// recorded for the item it belongs to (shown again if the user steps back to it) and never
|
||||
// painted on the next one; unkeyed it landed last and painted the NEXT video as watched, and a
|
||||
// corrective click then sent onState(next, "new") for an item that was already new.
|
||||
// The newest word wins: this toggle's own mark, or one the session confirmed elsewhere since the
|
||||
// player opened (the related row's own menu, a notice's Undo — `useVideoMarks`), else the item as
|
||||
// it was opened. The toggle alone missed those, and its first click re-sent the same status.
|
||||
const [marks, setMarks] = useState<Record<string, { status: VideoStatus; at: number }>>({});
|
||||
const [openedAt] = useState(() => Date.now());
|
||||
const sessionMarks = useVideoMarks();
|
||||
const own = marks[subject.id];
|
||||
const confirmed = markedValue(sessionMarks, subject.id, "status");
|
||||
const elsewhere = confirmed && confirmed.at >= openedAt ? confirmed : undefined;
|
||||
const status =
|
||||
own && (!elsewhere || own.at >= elsewhere.at)
|
||||
? own.status
|
||||
: (elsewhere?.value ?? subject.status);
|
||||
const watched = status === "watched";
|
||||
const setWatched = (on: boolean) => {
|
||||
const next = on ? "watched" : "new";
|
||||
setMarks((m) => ({ ...m, [subject.id]: next }));
|
||||
setMarks((m) => ({ ...m, [subject.id]: { status: next, at: Date.now() } }));
|
||||
onState(subject.id, next, subject);
|
||||
};
|
||||
|
||||
@@ -727,8 +739,11 @@ export default function PlayerModal({
|
||||
setRelatedOpen(false);
|
||||
// The video already playing (its row stays in the list): a new player for the same id would
|
||||
// never be built (one per video), so a Restart is a seek, and a plain pick just keeps playing.
|
||||
// Unless a description link put another video in the player meanwhile (`navigated`): then the
|
||||
// pick goes back to this one — the seek moved the linked video, and the pick did nothing.
|
||||
if (v.id === subject.id) {
|
||||
if (startAt != null) seekTo(startAt);
|
||||
if (navigated) loadVideo(subject.id, startAt);
|
||||
else if (startAt != null) seekTo(startAt);
|
||||
return;
|
||||
}
|
||||
setRelated({ video: v, startAt });
|
||||
|
||||
@@ -69,11 +69,11 @@ const page = (items: RelatedVideo[], next: string | null = null) => ({
|
||||
function setup(
|
||||
onPlay: (v: Video, startAt: number | null) => void = () => {},
|
||||
onWrite?: (write: Promise<unknown>) => void,
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
) {
|
||||
vi.spyOn(api, "comfortPins").mockResolvedValue({ items: [] } as never);
|
||||
vi.spyOn(api, "downloadIndex").mockResolvedValue({});
|
||||
const saved = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
@@ -343,6 +343,19 @@ describe("RelatedVideos", () => {
|
||||
expect(titles()).toEqual(["Video C", "Video B"]);
|
||||
});
|
||||
|
||||
// Hidden in this session, then un-hidden on another device: a list read after the mark carries
|
||||
// the server's word, and the mark does not paint over it.
|
||||
it("shows a list read after a mark as the server has it", async () => {
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await writeVideoState(qc, "A", "hidden");
|
||||
await new Promise((r) => setTimeout(r, 5)); // the list is read a moment after the mark
|
||||
setup(undefined, undefined, qc);
|
||||
await title("Video B");
|
||||
expect(titles()).toEqual(["Video A", "Video B"]);
|
||||
});
|
||||
|
||||
// The Notification Center's undo, the feed's reset, a card elsewhere: every write reaches it.
|
||||
it("follows a video's state written anywhere else", async () => {
|
||||
vi.spyOn(api, "related").mockResolvedValue(page([row("A"), row("B")]));
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { nextPageOnClick } from "../../lib/nextPage";
|
||||
import { usePref, type UsePrefOptions } from "../../lib/usePref";
|
||||
import { useVideoMarks } from "../../lib/progressCache";
|
||||
import { markedRows, stampFetch, useVideoMarks } from "../../lib/progressCache";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||
@@ -68,7 +68,7 @@ export function RelatedVideos({
|
||||
// another slot of the day's cap.
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: qk.related(videoId, region),
|
||||
queryFn: ({ pageParam }) => api.related(videoId, region, pageParam),
|
||||
queryFn: ({ pageParam }) => stampFetch(() => api.related(videoId, region, pageParam)),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => (last.has_more ? last.next_cursor : undefined),
|
||||
staleTime: Infinity,
|
||||
@@ -128,11 +128,10 @@ export function RelatedVideos({
|
||||
const menu = useContextMenu<Video>();
|
||||
const openId = menu.open ? menu.target?.id : undefined;
|
||||
|
||||
// What this session confirmed about a row (here or anywhere else), then what is on its way.
|
||||
// What this session confirmed about a row since its page was read (here or anywhere else), then
|
||||
// what is on its way.
|
||||
const marks = useVideoMarks();
|
||||
const rows = (query.data?.pages ?? [])
|
||||
.flatMap((p) => p.items)
|
||||
.map((v) => (marks[v.id] ? { ...v, ...marks[v.id] } : v))
|
||||
const rows = markedRows(query.data?.pages, marks)
|
||||
.map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
|
||||
.filter((v) => !isSeen(v));
|
||||
const followed = new Set(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The queue transport cluster, in both of its places. The auto-advance mode wears glyphs of its own:
|
||||
// on the side panel's glyph-only row the skip arrows are Previous / Next, and a mode that wore them
|
||||
// too put two identical ⏭ side by side, the second one silently changing a saved setting. The modes
|
||||
// are cycles, not switches (lit, no aria-pressed), and every control's tooltip names it and its mode
|
||||
// in both places. i18n is key-passthrough.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import { PlayerTransportControls } from "./playerModalControls";
|
||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function mount(variant: "modal" | "docked", autoMode: AutoMode, loopMode: LoopMode = "off") {
|
||||
render(
|
||||
<PlayerTransportControls
|
||||
variant={variant}
|
||||
index={1}
|
||||
queueLength={3}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={() => {}}
|
||||
onNext={() => {}}
|
||||
onCycleAuto={() => {}}
|
||||
onCycleLoop={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const glyph = (el: HTMLElement) => el.querySelector("svg")!.getAttribute("class");
|
||||
const button = (name: string | RegExp) => screen.getByRole("button", { name });
|
||||
|
||||
describe("PlayerTransportControls", () => {
|
||||
for (const variant of ["modal", "docked"] as const)
|
||||
for (const mode of AUTO_MODES)
|
||||
it(`${variant}: auto-advance ${mode} wears neither Previous's glyph nor Next's`, () => {
|
||||
mount(variant, mode);
|
||||
const auto = glyph(button(/^player\.autoAdvance\.label: /));
|
||||
expect(auto).not.toBe(glyph(button("player.previous")));
|
||||
expect(auto).not.toBe(glyph(button("player.next")));
|
||||
});
|
||||
|
||||
for (const variant of ["modal", "docked"] as const)
|
||||
it(`${variant}: the modes are lit when on, and announce no toggle`, () => {
|
||||
mount(variant, "next", "off");
|
||||
const auto = button(/^player\.autoAdvance\.label: /);
|
||||
const loop = button(/^player\.loop\.label: /);
|
||||
expect(auto.hasAttribute("aria-pressed")).toBe(false);
|
||||
expect(loop.hasAttribute("aria-pressed")).toBe(false);
|
||||
expect(auto.hasAttribute("data-lit")).toBe(true);
|
||||
expect(loop.hasAttribute("data-lit")).toBe(false);
|
||||
});
|
||||
|
||||
for (const variant of ["modal", "docked"] as const)
|
||||
it(`${variant}: each mode's tooltip names it and the mode it is in`, () => {
|
||||
for (const loopMode of LOOP_MODES) {
|
||||
mount(variant, "random", loopMode);
|
||||
expect(button(/^player\.autoAdvance\.label: /).title).toBe(
|
||||
"player.autoAdvance.label: player.autoAdvance.random · player.autoAdvance.hint",
|
||||
);
|
||||
expect(button(/^player\.loop\.label: /).title).toBe(
|
||||
`player.loop.label: player.loop.${loopMode} · player.loop.hint`,
|
||||
);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties, Ref } from "react";
|
||||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||
import { ListEnd, ListStart, Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||
import type { VideoDetail } from "../../lib/api";
|
||||
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
@@ -13,13 +13,16 @@ 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`.
|
||||
|
||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
||||
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
||||
* theme, glyphs only so the whole cluster fits one line of the side panel) and the big-view docked
|
||||
* control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). Only the theme +
|
||||
* label visibility differ; the behaviour is one, so a mode/icon change lands once. Every control keeps
|
||||
* its full name (with the shortcut) as its tooltip and accessible name, so a glyph-only one still says
|
||||
* what it does and which mode it is in. The wrapper (fragment vs. bordered row) stays at the call site. */
|
||||
/** 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, glyphs only so the whole cluster fits one line of the side panel) and the big-view
|
||||
* docked control bar (`variant:"docked"`, white-on-dark, labels shown from a breakpoint up). What a
|
||||
* control does, its glyph and its tooltip are said once; only the primitive and the label's
|
||||
* visibility differ. Every control's tooltip carries its full name (and the shortcut, where it has
|
||||
* one), so a glyph-only one still says what it does and which mode it is in. The modes are cycles,
|
||||
* not switches: `lit` when not off, and the name says which. Auto-advance wears list glyphs of its
|
||||
* own — the skip arrows are Previous / Next, beside it (DESIGN.md "A glyph means one thing"). The
|
||||
* wrapper (fragment vs. bordered row) stays at the call site. */
|
||||
export function PlayerTransportControls({
|
||||
variant,
|
||||
index,
|
||||
@@ -47,102 +50,101 @@ export function PlayerTransportControls({
|
||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||
const autoName = `${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`;
|
||||
const loopName = `${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`;
|
||||
const autoIcon =
|
||||
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />;
|
||||
const loopIcon = loopMode === "one" ? <Repeat1 /> : <Repeat />;
|
||||
const count = (
|
||||
type Control = {
|
||||
key: string;
|
||||
glyph: ReactNode;
|
||||
name: string;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
lit?: boolean;
|
||||
/** From which width the docked bar shows the name beside the glyph. */
|
||||
labelFrom: "sm" | "md";
|
||||
/** The glyph after the name (Next points the way it goes). */
|
||||
trailing?: boolean;
|
||||
};
|
||||
const step: Control[] = [
|
||||
{
|
||||
key: "prev",
|
||||
glyph: <SkipBack />,
|
||||
name: t("player.previous"),
|
||||
title: `${t("player.previous")} · Shift+←`,
|
||||
onClick: onPrev,
|
||||
disabled: index === 0,
|
||||
labelFrom: "sm",
|
||||
},
|
||||
{
|
||||
key: "next",
|
||||
glyph: <SkipForward />,
|
||||
name: t("player.next"),
|
||||
title: `${t("player.next")} · Shift+→`,
|
||||
onClick: onNext,
|
||||
disabled: index === queueLength - 1,
|
||||
labelFrom: "sm",
|
||||
trailing: true,
|
||||
},
|
||||
];
|
||||
// Persistent playback settings — cycle on click, saved to your account.
|
||||
const modes: Control[] = [
|
||||
{
|
||||
key: "auto",
|
||||
glyph:
|
||||
autoMode === "prev" ? <ListStart /> : autoMode === "random" ? <Shuffle /> : <ListEnd />,
|
||||
name: autoName,
|
||||
title: `${autoName} · ${t("player.autoAdvance.hint")}`,
|
||||
onClick: onCycleAuto,
|
||||
lit: autoMode !== "off",
|
||||
labelFrom: "md",
|
||||
},
|
||||
{
|
||||
key: "loop",
|
||||
glyph: loopMode === "one" ? <Repeat1 /> : <Repeat />,
|
||||
name: loopName,
|
||||
title: `${loopName} · ${t("player.loop.hint")}`,
|
||||
onClick: onCycleLoop,
|
||||
lit: loopMode !== "off",
|
||||
labelFrom: "md",
|
||||
},
|
||||
];
|
||||
const render = (c: Control) =>
|
||||
docked ? (
|
||||
<Button
|
||||
key={c.key}
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
icon={c.trailing ? undefined : c.glyph}
|
||||
onClick={c.onClick}
|
||||
disabled={c.disabled}
|
||||
lit={c.lit}
|
||||
label={c.name}
|
||||
title={c.title}
|
||||
>
|
||||
<span className={c.labelFrom === "sm" ? "hidden sm:inline" : "hidden md:inline"}>
|
||||
{c.name}
|
||||
</span>
|
||||
{c.trailing && c.glyph}
|
||||
</Button>
|
||||
) : (
|
||||
<IconButton
|
||||
key={c.key}
|
||||
onClick={c.onClick}
|
||||
disabled={c.disabled}
|
||||
lit={c.lit}
|
||||
label={c.name}
|
||||
title={c.title}
|
||||
>
|
||||
{c.glyph}
|
||||
</IconButton>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{render(step[0]!)}
|
||||
<span className={counter}>
|
||||
{index + 1} / {queueLength}
|
||||
</span>
|
||||
);
|
||||
if (!docked)
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
>
|
||||
<SkipBack />
|
||||
</IconButton>
|
||||
{count}
|
||||
<IconButton
|
||||
onClick={onNext}
|
||||
disabled={index === queueLength - 1}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
>
|
||||
<SkipForward />
|
||||
</IconButton>
|
||||
{render(step[1]!)}
|
||||
<span className={divider} />
|
||||
<IconButton
|
||||
pressed={autoMode !== "off"}
|
||||
onClick={onCycleAuto}
|
||||
label={autoName}
|
||||
title={`${autoName} · ${t("player.autoAdvance.hint")}`}
|
||||
>
|
||||
{autoIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
pressed={loopMode !== "off"}
|
||||
onClick={onCycleLoop}
|
||||
label={loopName}
|
||||
title={`${loopName} · ${t("player.loop.hint")}`}
|
||||
>
|
||||
{loopIcon}
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
icon={<SkipBack />}
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("player.previous")}</span>
|
||||
</Button>
|
||||
{count}
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={index === queueLength - 1}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("player.next")}</span>
|
||||
<SkipForward />
|
||||
</Button>
|
||||
<span className={divider} />
|
||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={autoMode !== "off"}
|
||||
icon={autoIcon}
|
||||
onClick={onCycleAuto}
|
||||
label={autoName}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
>
|
||||
<span className="hidden md:inline">{autoName}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={loopMode !== "off"}
|
||||
icon={loopIcon}
|
||||
onClick={onCycleLoop}
|
||||
label={loopName}
|
||||
title={t("player.loop.hint")}
|
||||
>
|
||||
<span className="hidden md:inline">{loopName}</span>
|
||||
</Button>
|
||||
{modes.map(render)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api, HttpError, type PlexCollection } from "../../lib/api";
|
||||
import { api, refusalReason, type PlexCollection } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
@@ -123,8 +123,7 @@ export default function PlexCollectionAddAt({
|
||||
/** A create refused only because the read after it failed: the collection is on Plex, with this
|
||||
* title in it, and its row is kept — the name is taken now, so the field clears as on success. */
|
||||
function madeOnPlex(e: unknown): boolean {
|
||||
const d = e instanceof HttpError ? (e.detailData as { reason?: string } | undefined) : undefined;
|
||||
return d?.reason === "collection_pending_sync";
|
||||
return refusalReason(e) === "collection_pending_sync";
|
||||
}
|
||||
|
||||
function flipContains<T extends { collections: { id: string; contains: boolean }[] }>(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { qk } from "../../lib/queryKeys";
|
||||
import { ListPlus } from "lucide-react";
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { api, type Playlist } from "../../lib/api";
|
||||
import { addPlaylistItem, removePlaylistItem } from "./videoWrites";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { playlistName } from "../../lib/playlistName";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
@@ -115,8 +116,8 @@ function MembershipPanel({
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||
else await api.addToPlaylist(pl.id, videoId);
|
||||
if (pl.has_video) await removePlaylistItem(qc, pl, videoId);
|
||||
else await addPlaylistItem(qc, pl, videoId);
|
||||
qc.setQueryData<Playlist[]>(qk.playlistMembership(videoId), (old) =>
|
||||
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
|
||||
);
|
||||
@@ -133,7 +134,7 @@ function MembershipPanel({
|
||||
let made = false;
|
||||
try {
|
||||
const pl = await api.createPlaylist(name);
|
||||
await api.addToPlaylist(pl.id, videoId);
|
||||
await addPlaylistItem(qc, pl, videoId);
|
||||
made = true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
|
||||
@@ -16,19 +16,29 @@ const ALLOWED = new Set([
|
||||
"features/video/videoWrites.ts",
|
||||
]);
|
||||
|
||||
// A related list and a live search are never refetched, so a state or Watch-later write that does
|
||||
// not reach them (`markVideo`) leaves them showing the old mark for the session. videoWrites makes
|
||||
// those writes and the mark together; a direct api call skips the mark.
|
||||
describe("a video's state and Watch-later writes", () => {
|
||||
// A related list and a live search are never refetched, so a state, Watch-later or playlist-item
|
||||
// write that does not reach them (`markVideo`) leaves them showing the old mark for the session —
|
||||
// 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/;
|
||||
|
||||
describe("a video's state, Watch-later and playlist-item writes", () => {
|
||||
it("go through features/video/videoWrites, never the api directly", () => {
|
||||
const offenders = sources()
|
||||
.filter(({ f }) => !ALLOWED.has(f))
|
||||
// Only a call on the api object counts (React's own `setState` is not ours); the dot may sit
|
||||
// on the next line of a chain.
|
||||
.filter(({ src }) =>
|
||||
/\bapi\s*\.\s*(setState|clearState|watchLaterAdd|watchLaterRemove)\(/.test(src),
|
||||
)
|
||||
// Only the api object's members count (React's own `setState` is not ours) — named, not only
|
||||
// called: `(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)` makes the write too. The
|
||||
// dot may sit on the next line of a chain.
|
||||
.filter(({ src }) => WRITES.test(src))
|
||||
.map(({ f }) => f);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches a write named without being called on the spot, and a playlist item's", () => {
|
||||
expect(WRITES.test("(saved ? api.watchLaterAdd : api.watchLaterRemove)(id)")).toBe(true);
|
||||
expect(WRITES.test("await api\n .removeFromPlaylist(listId, id)")).toBe(true);
|
||||
expect(WRITES.test("api.moveToPlaylist(a, id, b)")).toBe(true);
|
||||
expect(WRITES.test("this.setState(next); api.playlists()")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,31 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { resetVideoState, writeVideoState, writeWatchLater } from "./videoWrites";
|
||||
import {
|
||||
addPlaylistItem,
|
||||
movePlaylistItem,
|
||||
removePlaylistItem,
|
||||
resetVideoState,
|
||||
writeVideoState,
|
||||
writeWatchLater,
|
||||
} from "./videoWrites";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const marks = (qc: QueryClient) => qc.getQueryData(qk.videoMarks());
|
||||
// The marks' values, without when each was confirmed.
|
||||
const marks = (qc: QueryClient) => {
|
||||
const m = qc.getQueryData<Record<string, Record<string, { value: unknown }>>>(qk.videoMarks());
|
||||
return m
|
||||
? Object.fromEntries(
|
||||
Object.entries(m).map(([id, f]) => [
|
||||
id,
|
||||
Object.fromEntries(Object.entries(f).map(([k, x]) => [k, x.value])),
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
};
|
||||
const WATCH_LATER = { id: 1, kind: "watch_later" as const };
|
||||
const MINE = { id: 2, kind: "user" as const };
|
||||
|
||||
describe("videoWrites", () => {
|
||||
it("marks a state written", async () => {
|
||||
@@ -38,6 +58,34 @@ describe("videoWrites", () => {
|
||||
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||
});
|
||||
|
||||
it("marks an item of the Watch later list as the bookmark it is", async () => {
|
||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
await addPlaylistItem(qc, WATCH_LATER, "X");
|
||||
expect(marks(qc)).toEqual({ X: { saved: true } });
|
||||
await removePlaylistItem(qc, WATCH_LATER, "X");
|
||||
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||
});
|
||||
|
||||
it("marks nothing for an item of any other list", async () => {
|
||||
vi.spyOn(api, "addToPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
await addPlaylistItem(qc, MINE, "X");
|
||||
await removePlaylistItem(qc, { id: 3, kind: undefined }, "X");
|
||||
expect(marks(qc)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks a move into and out of the Watch later list", async () => {
|
||||
vi.spyOn(api, "moveToPlaylist").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
await movePlaylistItem(qc, MINE, "X", WATCH_LATER);
|
||||
expect(marks(qc)).toEqual({ X: { saved: true } });
|
||||
await movePlaylistItem(qc, WATCH_LATER, "X", MINE);
|
||||
expect(marks(qc)).toEqual({ X: { saved: false } });
|
||||
});
|
||||
|
||||
it("marks nothing the server refused", async () => {
|
||||
vi.spyOn(api, "setState").mockRejectedValue(new Error("offline"));
|
||||
const qc = new QueryClient();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, type VideoStatus } from "../../lib/api";
|
||||
import { api, type Playlist, type VideoStatus } from "../../lib/api";
|
||||
import { markVideo } from "../../lib/progressCache";
|
||||
|
||||
// A video's user state and its Watch-later mark, written to the server and — 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 related list hiding a video the user had un-hidden,
|
||||
// or showing it unsaved after a save. Each returns the server's answer, so a caller keeps its own
|
||||
// optimistic layer, notices and rollback.
|
||||
// A video's user state, its Watch-later mark and its playlist items, written to the server and —
|
||||
// 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
|
||||
// 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
|
||||
// returns the server's answer, so a caller keeps its own optimistic layer, notices and rollback.
|
||||
|
||||
export function writeVideoState(qc: QueryClient, id: string, status: VideoStatus) {
|
||||
return api.setState(id, status).then((r) => {
|
||||
@@ -32,3 +34,36 @@ export function writeWatchLater(qc: QueryClient, id: string, saved: boolean) {
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
/** The list a playlist item write goes to: on the built-in Watch later list (`kind`) the item IS
|
||||
* the video's Watch-later mark. A kind the caller cannot tell (undefined) marks nothing. */
|
||||
type ListRef = { id: number; kind: Playlist["kind"] | undefined };
|
||||
|
||||
const markIfWatchLater = (qc: QueryClient, list: ListRef, id: string, saved: boolean) => {
|
||||
if (list.kind === "watch_later") markVideo(qc, id, { saved });
|
||||
};
|
||||
|
||||
/** Add a video to a playlist; `position` (an index in the stored order) puts it back where a
|
||||
* removal took it from. */
|
||||
export function addPlaylistItem(qc: QueryClient, list: ListRef, id: string, position?: number) {
|
||||
return api.addToPlaylist(list.id, id, position).then((r) => {
|
||||
markIfWatchLater(qc, list, id, true);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
export function removePlaylistItem(qc: QueryClient, list: ListRef, id: string) {
|
||||
return api.removeFromPlaylist(list.id, id).then((r) => {
|
||||
markIfWatchLater(qc, list, id, false);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
/** Move a video from one playlist to another, in one request (see `api.moveToPlaylist`). */
|
||||
export function movePlaylistItem(qc: QueryClient, from: ListRef, id: string, to: ListRef) {
|
||||
return api.moveToPlaylist(from.id, id, to.id).then((r) => {
|
||||
markIfWatchLater(qc, from, id, false);
|
||||
markIfWatchLater(qc, to, id, true);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
},
|
||||
"related_daily_limit_per_user": {
|
||||
"label": "Related videos — daily limit per user",
|
||||
"hint": "Max related-video requests a user can make per day — opening a list and each Show more count one. They come from YouTube's website and spend no search quota; looking up the videos in one costs up to about 8 units (up to 4 pages, each a video and a channel lookup). A failed one doesn't count. Set 0 to turn related videos off."
|
||||
"hint": "Max related-video requests a user can make per day — opening a list counts one, and so does every “Show more”. They come from YouTube's website and spend no search quota; looking up the videos in one costs up to about 8 units (up to 4 pages, each a video and a channel lookup). A failed one doesn't count (up to 5 an hour). Set 0 to turn related videos off."
|
||||
},
|
||||
"search_daily_limit_per_user": {
|
||||
"label": "Live search — daily limit per user",
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"recipient_required": "A recipient email is required",
|
||||
"registration_closed": "Registration is currently closed",
|
||||
"related_disabled": "Related videos are turned off on this instance.",
|
||||
"related_daily_limit": "You've reached today's related videos limit ({{limit}}). Opening a list and each Show more count as one request. Try again tomorrow.",
|
||||
"related_daily_limit": "You've reached today's related videos limit ({{limit}}). Opening a list counts as one request, and so does every “Show more”. Try again tomorrow.",
|
||||
"search_disabled": "Live YouTube search is turned off on this instance.",
|
||||
"search_daily_limit": "You've reached today's YouTube search limit ({{limit}}). Try again tomorrow.",
|
||||
"search_term_required": "Enter something to search for",
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
},
|
||||
"related_daily_limit_per_user": {
|
||||
"label": "Kapcsolódó videók — napi limit / felhasználó",
|
||||
"hint": "Hányszor kérhet kapcsolódó videókat egy felhasználó naponta — egy lista megnyitása és minden „Mutass többet” egynek számít. A YouTube weboldaláról jönnek, keresési kvótát nem fogyasztanak; a bennük lévő videók lekérése kérésenként legfeljebb kb. 8 egység (legfeljebb 4 oldal, mindegyik egy videó- és egy csatornalekérés). A sikertelen nem számít. 0 = a kapcsolódó videók kikapcsolva."
|
||||
"hint": "Hányszor kérhet kapcsolódó videókat egy felhasználó naponta — egy lista megnyitása és minden „Mutass többet” egynek számít. A YouTube weboldaláról jönnek, keresési kvótát nem fogyasztanak; a bennük lévő videók lekérése kérésenként legfeljebb kb. 8 egység (legfeljebb 4 oldal, mindegyik egy videó- és egy csatornalekérés). A sikertelen nem számít (óránként legfeljebb 5). 0 = a kapcsolódó videók kikapcsolva."
|
||||
},
|
||||
"search_daily_limit_per_user": {
|
||||
"label": "Élő keresés — napi limit / felhasználó",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
clearEntryUrl,
|
||||
reloadAfterRefusal,
|
||||
} from "./entryUrl";
|
||||
import { HttpError } from "./api/core";
|
||||
|
||||
const notice = (search: string, hash = "") =>
|
||||
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
|
||||
@@ -112,14 +113,14 @@ describe("a tab whose session the server refused", () => {
|
||||
|
||||
it("reloads as the account signed in now, saying why, when it showed another", () => {
|
||||
const loc = stubLocation();
|
||||
reloadAfterRefusal({ detailData: { code: "refused", reason: "account_changed" } });
|
||||
reloadAfterRefusal(new HttpError(401, "x", { code: "refused", reason: "account_changed" }));
|
||||
expect(loc.replace).toHaveBeenCalledWith(`/?page=stats&${ACCOUNT_CHANGED_FLAG}`);
|
||||
expect(loc.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads plainly when the session ended", () => {
|
||||
const loc = stubLocation();
|
||||
reloadAfterRefusal({ detailData: { code: "refused", reason: "not_authenticated" } });
|
||||
reloadAfterRefusal(new HttpError(401, "x", { code: "refused", reason: "not_authenticated" }));
|
||||
expect(loc.reload).toHaveBeenCalled();
|
||||
expect(loc.replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// with nothing personal in them (login, deleted, account) ride the query string.
|
||||
|
||||
import type { NotifLevel } from "./notifications";
|
||||
import { refusalReason } from "./api/core";
|
||||
import { hrefHere } from "./urlState";
|
||||
|
||||
export const entrySearch = new URLSearchParams(window.location.search);
|
||||
@@ -45,9 +46,8 @@ export function accountChangedUrl(): string {
|
||||
* signed in now (`account_changed`: a sign-in as someone else in another tab), it boots as that one
|
||||
* and says why — a Sign out that silently left someone else signed in would read as broken. Any
|
||||
* other refusal means the session ended: a plain reload lands on the sign-in page. */
|
||||
export function reloadAfterRefusal(err: { detailData?: unknown }): void {
|
||||
if ((err.detailData as { reason?: string } | undefined)?.reason === "account_changed")
|
||||
window.location.replace(accountChangedUrl());
|
||||
export function reloadAfterRefusal(err: unknown): void {
|
||||
if (refusalReason(err) === "account_changed") window.location.replace(accountChangedUrl());
|
||||
else window.location.reload();
|
||||
}
|
||||
|
||||
|
||||
@@ -489,7 +489,8 @@ export const setListInTopLayerProbe = (probe: () => boolean): void => {
|
||||
};
|
||||
|
||||
// A surface whose keys wait while a layer sits above it (the YouTube player under its related
|
||||
// drawer or a dialog: every key but Escape is the layer's then). Its rows would promise keys that do
|
||||
// drawer or a dialog: every key but Escape is the layer's then; the file viewer under its menu or
|
||||
// Share dialog). Its rows would promise keys that do
|
||||
// nothing, so the sheet leaves the scope out — asked at the same moment as `helpOverLayer`.
|
||||
const coveredProbes = new Map<KeyScope, () => boolean>();
|
||||
export const setScopeCoveredProbe = (scope: KeyScope, probe: (() => boolean) | null): void => {
|
||||
|
||||
@@ -563,10 +563,11 @@ export function useYtProgress({
|
||||
// `reclaimFocus`, `qc`, … — are a PER-ITEM SNAPSHOT, taken once through `itemSnapshot` when this
|
||||
// player is built: they describe the video it was built for, and re-reading them mid-session
|
||||
// would be the bug, not the fix. `setWatched` is the clearest case: bound to the item, it lets
|
||||
// this cleanup's late auto-mark land on the item being LEFT, which the modal then ignores. What
|
||||
// genuinely has to stay live mid-session is behind refs (currentIdRef, modeRef, navRef,
|
||||
// sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and THAT split is the enforcement:
|
||||
// a value that is not a snapshot does not belong in this closure. (This replaced an
|
||||
// exhaustive-deps suppression, which made the React Compiler skip the whole hook.)
|
||||
// this cleanup's late auto-mark land on the item being LEFT, which the modal records for that
|
||||
// item and never paints on the next one. What genuinely has to stay live mid-session is behind
|
||||
// refs (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef)
|
||||
// — and THAT split is the enforcement: a value that is not a snapshot does not belong in this
|
||||
// closure. (This replaced an exhaustive-deps suppression, which made the React Compiler skip
|
||||
// the whole hook.)
|
||||
}, [activeId, mountRef, playerRef, currentIdRef, navRef, modeRef, sessionPosRef]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// What a confirmed mark reaches. A related list and a live YouTube search are never refetched (a
|
||||
// scrape and a slot of the day's cap, or 100 quota units), so the mark is written into them; and it is
|
||||
// kept apart from them too, where a page landing later cannot take it back.
|
||||
import { describe, expect, it } from "vitest";
|
||||
// kept apart from them too, laid over each page read before it (a page landing later, from a fetch
|
||||
// that started earlier, cannot take it back) and over none read after (those carry the server's word).
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { markVideo, patchCachedProgress } from "./progressCache";
|
||||
import { markedRows, markVideo, patchCachedProgress } from "./progressCache";
|
||||
import { qk } from "./queryKeys";
|
||||
import type { Video } from "./api";
|
||||
|
||||
@@ -30,9 +31,48 @@ describe("markVideo", () => {
|
||||
markVideo(qc, "X", { saved: true });
|
||||
patchCachedProgress(qc, new Map([["X", 90]]));
|
||||
expect(qc.getQueryData(qk.videoMarks())).toEqual({
|
||||
X: { status: "hidden", saved: true, position_seconds: 90 },
|
||||
X: {
|
||||
status: { value: "hidden", at: expect.any(Number) },
|
||||
saved: { value: true, at: expect.any(Number) },
|
||||
position_seconds: { value: 90, at: expect.any(Number) },
|
||||
},
|
||||
});
|
||||
// Nothing observes it before a related list opens: it must not be collected meanwhile.
|
||||
expect(qc.getQueryCache().find({ queryKey: qk.videoMarks() })?.options.gcTime).toBe(Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markedRows", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const marked = (qc: QueryClient) =>
|
||||
qc.getQueryData(qk.videoMarks()) as Parameters<typeof markedRows>[1];
|
||||
|
||||
it("lays a mark over a page whose fetch started before it", () => {
|
||||
vi.useFakeTimers({ now: 2000 });
|
||||
const qc = new QueryClient();
|
||||
markVideo(qc, "X", { saved: true });
|
||||
// A Show more that started at 1000 and landed after the mark: built from the pages it started from.
|
||||
const rows = markedRows([{ items: [v("X")], fetchedAt: 1000 }], marked(qc));
|
||||
expect(rows[0]!.saved).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a page read after the mark with the server's word", () => {
|
||||
vi.useFakeTimers({ now: 2000 });
|
||||
const qc = new QueryClient();
|
||||
markVideo(qc, "X", { saved: true, position_seconds: 40 });
|
||||
// Taken off Watch later on the Playlists page, played on another device: the server says so.
|
||||
const fresh = { ...v("X"), saved: false, position_seconds: 300 };
|
||||
const rows = markedRows([{ items: [fresh], fetchedAt: 3000 }], marked(qc));
|
||||
expect(rows[0]).toEqual(fresh);
|
||||
});
|
||||
|
||||
it("weighs each field by when it was marked", () => {
|
||||
const qc = new QueryClient();
|
||||
vi.useFakeTimers({ now: 1000 });
|
||||
markVideo(qc, "X", { saved: true });
|
||||
vi.setSystemTime(3000);
|
||||
markVideo(qc, "X", { status: "hidden" });
|
||||
const rows = markedRows([{ items: [v("X")], fetchedAt: 2000 }], marked(qc));
|
||||
expect(rows[0]).toMatchObject({ saved: false, status: "hidden" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,18 +63,26 @@ export function markVideo(qc: QueryClient, id: string, change: Partial<Video>):
|
||||
remember(qc, id, change);
|
||||
}
|
||||
|
||||
// What this session confirmed about each video, kept APART from the lists that show it. A list's
|
||||
// own cache cannot hold it alone: an infinite query builds its next page's data from the pages it
|
||||
// read when that fetch STARTED (react-query's infiniteQueryBehavior), so a mark confirmed while a
|
||||
// related list's Show more was on its way vanished when the page landed — for the session, since
|
||||
// those lists are never refetched. The related list lays these over its rows (`useVideoMarks`).
|
||||
type Marks = Record<string, Partial<Video>>;
|
||||
// What this session confirmed about each video, kept APART from the lists that show it, field by
|
||||
// field with WHEN. A list's own cache cannot hold it alone: an infinite query builds its next page's
|
||||
// data from the pages it read when that fetch STARTED (react-query's infiniteQueryBehavior), so a
|
||||
// mark confirmed while a Show more / Load more was on its way vanished when the page landed — for
|
||||
// the session, since those lists are never refetched. Laid over a page only where the mark is
|
||||
// newer than the page's fetch (`markedRows`): a page read later carries the server's own newer
|
||||
// word (a Watch-later change made on the Playlists page, a position saved on another device),
|
||||
// and a session-long mark painted over it would show the old one until a reload.
|
||||
type Mark = { [K in keyof Video]?: { value: Video[K]; at: number } };
|
||||
type Marks = Record<string, Mark>;
|
||||
|
||||
function remember(qc: QueryClient, id: string, change: Partial<Video>): void {
|
||||
const at = Date.now();
|
||||
const stamped = Object.fromEntries(
|
||||
Object.entries(change).map(([k, value]) => [k, { value, at }]),
|
||||
) as Mark;
|
||||
// Kept while the app is open, observed or not: a mark made before any related list was opened
|
||||
// must still be there when one is.
|
||||
qc.setQueryDefaults(qk.videoMarks(), { staleTime: Infinity, gcTime: Infinity });
|
||||
qc.setQueryData<Marks>(qk.videoMarks(), (m) => ({ ...m, [id]: { ...m?.[id], ...change } }));
|
||||
qc.setQueryData<Marks>(qk.videoMarks(), (m) => ({ ...m, [id]: { ...m?.[id], ...stamped } }));
|
||||
}
|
||||
|
||||
/** This session's confirmed marks, per video id (see `markVideo`). */
|
||||
@@ -90,6 +98,44 @@ export function useVideoMarks(): Marks {
|
||||
}).data;
|
||||
}
|
||||
|
||||
/** The newest confirmed value of one field of a video, or undefined when this session set none. */
|
||||
export const markedValue = <K extends keyof Video>(
|
||||
marks: Marks,
|
||||
id: string,
|
||||
field: K,
|
||||
): { value: Video[K]; at: number } | undefined => marks[id]?.[field];
|
||||
|
||||
/** A page of a never-refetched list, with when its fetch STARTED (`stampFetch`). */
|
||||
export type Stamped = { fetchedAt?: number };
|
||||
|
||||
/** Run a never-refetched list's page fetch, stamping the page with when it started — what
|
||||
* `markedRows` weighs a mark against. */
|
||||
export async function stampFetch<T extends object>(
|
||||
fetch: () => Promise<T>,
|
||||
): Promise<T & { fetchedAt: number }> {
|
||||
const fetchedAt = Date.now();
|
||||
return { ...(await fetch()), fetchedAt };
|
||||
}
|
||||
|
||||
/** A never-refetched list's rows, each with this session's marks laid over it where the mark is
|
||||
* newer than the page it came in (a page with no stamp takes every mark). */
|
||||
export function markedRows(
|
||||
pages: readonly ({ items: Video[] } & Stamped)[] | undefined,
|
||||
marks: Marks,
|
||||
): Video[] {
|
||||
return (pages ?? []).flatMap((page) =>
|
||||
page.items.map((v) => {
|
||||
const mark = marks[v.id];
|
||||
if (!mark) return v;
|
||||
const since = page.fetchedAt ?? -Infinity;
|
||||
const newer = Object.entries(mark).filter(([, m]) => m.at >= since);
|
||||
return newer.length
|
||||
? { ...v, ...Object.fromEntries(newer.map(([k, m]) => [k, m.value])) }
|
||||
: v;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply `edit` to every row of every cached list under `keys` (prefix keys), keeping each untouched
|
||||
* page's and list's identity — so an unchanged list does not re-render every card behind the
|
||||
* open player. */
|
||||
|
||||
@@ -41,7 +41,13 @@ import {
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
|
||||
import {
|
||||
markedRows,
|
||||
patchCachedProgress,
|
||||
progressFlushes,
|
||||
stampFetch,
|
||||
useVideoMarks,
|
||||
} from "../../lib/progressCache";
|
||||
import {
|
||||
buildSort,
|
||||
feedSortOptions,
|
||||
@@ -201,6 +207,7 @@ export default function Feed({
|
||||
null,
|
||||
);
|
||||
const qc = useQueryClient();
|
||||
const videoMarks = useVideoMarks();
|
||||
// Live search turned off on this instance: the empty feed offers no search that would be refused.
|
||||
const searchOn = useMe().search_enabled;
|
||||
|
||||
@@ -276,7 +283,7 @@ export default function Feed({
|
||||
const ytQuery = useInfiniteQuery({
|
||||
queryKey: qk.ytSearch(ytSearch, ytCount),
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
||||
stampFetch(() => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount)),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
enabled: ytActive,
|
||||
@@ -462,7 +469,10 @@ export default function Feed({
|
||||
);
|
||||
|
||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
||||
// A mark made anywhere else (the player's related list, the Notification Center's undo) since a
|
||||
// page was read is laid over it: a Load more on its way when the mark landed rebuilt the list
|
||||
// from the pages it started from, without the mark.
|
||||
const ytLoaded: Video[] = markedRows(ytQuery.data?.pages, videoMarks);
|
||||
// onState's refs follow each commit: itself, and what the cards show (live search results if on).
|
||||
useLayoutEffect(() => {
|
||||
onStateRef.current = onState;
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// A live YouTube search's Load more that fails. The results already read stay on screen and the
|
||||
// failure is said under them; before, the whole pane turned into the error text. Load more is the
|
||||
// retry where trying again can help, and is not offered after a refusal it cannot answer (the day's
|
||||
// cap, the feature turned off, the shared budget).
|
||||
// cap, the feature turned off, the shared budget). And a mark made elsewhere while a Load more is on
|
||||
// its way is not undone by the page landing.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
@@ -16,10 +17,12 @@ vi.mock("../../features/wizard/wizardContext", () => ({
|
||||
useWizard: () => ({ openWizard: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({ items }: { items: { id: string; title: string }[] }) => (
|
||||
default: ({ items }: { items: { id: string; title: string; status: string }[] }) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>{v.title}</li>
|
||||
<li key={v.id}>
|
||||
{v.title} ({v.status})
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
@@ -28,6 +31,7 @@ vi.mock("./VirtualFeed", () => ({
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, HttpError, type FeedFilters, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { writeVideoState } from "../../features/video/videoWrites";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -75,7 +79,10 @@ function setup(failure: HttpError) {
|
||||
vi.spyOn(api, "searchYoutube")
|
||||
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: "c2", source: "scrape" } as never)
|
||||
.mockRejectedValueOnce(failure);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return mount();
|
||||
}
|
||||
|
||||
function mount(qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
@@ -91,6 +98,7 @@ function setup(failure: HttpError) {
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
const loadMore = () => screen.queryByRole("button", { name: "feed.yt.loadMoreFree" });
|
||||
@@ -120,3 +128,37 @@ describe("Feed — a live search's Load more that fails", () => {
|
||||
await waitFor(() => expect(loadMore()).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feed — a live search's Load more on its way", () => {
|
||||
// Marked in this session, then changed back elsewhere: a search read after the mark carries the
|
||||
// server's word.
|
||||
it("shows a search read after a mark as the server has it", async () => {
|
||||
vi.spyOn(api, "searchYoutube").mockResolvedValue({
|
||||
items: [hit("a")],
|
||||
next_cursor: null,
|
||||
source: "scrape",
|
||||
} as never);
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await writeVideoState(qc, "a", "watched");
|
||||
await new Promise((r) => setTimeout(r, 5)); // the search is read a moment after the mark
|
||||
mount(qc);
|
||||
expect(await screen.findByText(/hit a/)).toBeTruthy();
|
||||
expect(screen.getByRole("list", { name: "results" }).textContent).toContain("hit a (new)");
|
||||
});
|
||||
|
||||
it("keeps a mark made elsewhere meanwhile when the page lands", async () => {
|
||||
let land: (p: unknown) => void = () => {};
|
||||
vi.spyOn(api, "searchYoutube")
|
||||
.mockResolvedValueOnce({ items: [hit("a")], next_cursor: "c2", source: "scrape" } as never)
|
||||
.mockReturnValueOnce(new Promise((r) => (land = r)) as never);
|
||||
vi.spyOn(api, "setState").mockResolvedValue({ ok: true } as never);
|
||||
const qc = mount();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "feed.yt.loadMoreFree" }));
|
||||
// Hidden from the player's related list, say: not one of the feed's own writes.
|
||||
await act(async () => void (await writeVideoState(qc, "a", "hidden")));
|
||||
await act(async () => land({ items: [hit("b")], next_cursor: null, source: "scrape" }));
|
||||
await screen.findByText(/hit b/);
|
||||
expect(screen.getByRole("list", { name: "results" }).textContent).not.toContain("hit a (new)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
|
||||
import FilePreview from "./FilePreview";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { closeKeyboardHelp, toggleKeyboardHelp, useKeyboardHelpCovered } from "../../lib/keymap";
|
||||
|
||||
const file = (id: number, preview: string | null, extra: Record<string, unknown> = {}) =>
|
||||
({
|
||||
@@ -170,6 +171,27 @@ describe("FilePreview · stepping", () => {
|
||||
expect(onFileChange).toHaveBeenLastCalledWith(3);
|
||||
});
|
||||
|
||||
// …so the help sheet opened over that layer leaves them out, and one over the viewer lists them.
|
||||
it("tells the help sheet its keys wait while a layer sits above it", () => {
|
||||
function Covered() {
|
||||
return <span data-testid="covered">{[...useKeyboardHelpCovered()].join(",")}</span>;
|
||||
}
|
||||
mount(list, 2);
|
||||
render(<Covered />);
|
||||
const help = () => act(() => toggleKeyboardHelp());
|
||||
const confirm = pushLayer();
|
||||
try {
|
||||
help();
|
||||
expect(screen.getByTestId("covered").textContent).toBe("fileViewer");
|
||||
act(() => closeKeyboardHelp());
|
||||
} finally {
|
||||
removeLayer(confirm);
|
||||
}
|
||||
help();
|
||||
expect(screen.getByTestId("covered").textContent).toBe("");
|
||||
act(() => closeKeyboardHelp());
|
||||
});
|
||||
|
||||
it("a text line wider than the stage scrolls sideways under ← / →, it does not page", async () => {
|
||||
vi.spyOn(api, "previewText").mockResolvedValue({ text: "a very long line", truncated: false });
|
||||
const { onFileChange } = mount([file(1, "image"), file(8, "text"), file(9, "text")], 8);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
||||
import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
@@ -85,6 +85,12 @@ export default function FilePreview({
|
||||
// child first, so it sits just ABOVE the dialog's layer: the dialog is never topmost, and Escape is
|
||||
// this layer's to take — passive, so the dialog keeps its Tab trap.
|
||||
const layer = useLayer(!!file, onClose, { passive: true });
|
||||
// …and the help sheet opened over that layer must not list them (keymap `setScopeCoveredProbe`).
|
||||
const coveredNow = useEffectEvent(() => layer.blockingAbove());
|
||||
useEffect(() => {
|
||||
setScopeCoveredProbe("fileViewer", () => coveredNow());
|
||||
return () => setScopeCoveredProbe("fileViewer", null);
|
||||
}, []);
|
||||
const onKey = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (layer.blockingAbove()) return;
|
||||
// The text, scrolled sideways by its arrows: a line wider than the stage is read that way.
|
||||
|
||||
@@ -3,7 +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 { writeVideoState } from "../../features/video/videoWrites";
|
||||
import {
|
||||
addPlaylistItem,
|
||||
movePlaylistItem,
|
||||
removePlaylistItem,
|
||||
writeVideoState,
|
||||
} from "../../features/video/videoWrites";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -301,6 +306,12 @@ function PlaylistDetail() {
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const detail = detailQuery.data;
|
||||
// Which kind of list an item write goes to (the Watch later list's items are the bookmark):
|
||||
// the rail's list knows every one, the open detail the one on screen.
|
||||
const listRef = (id: number) => ({
|
||||
id,
|
||||
kind: playlists.find((p) => p.id === id)?.kind ?? (detail?.id === id ? detail.kind : undefined),
|
||||
});
|
||||
// The page's background menu (DESIGN "Background menu"), live only while a list is on screen.
|
||||
const pageMenu = usePageMenu(selectedId != null && !!detail);
|
||||
const pagePin = useComfortPin(pageMenu.open);
|
||||
@@ -503,7 +514,7 @@ function PlaylistDetail() {
|
||||
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
|
||||
);
|
||||
try {
|
||||
await api.removeFromPlaylist(listId, video.id);
|
||||
await removePlaylistItem(qc, listRef(listId), video.id);
|
||||
refreshAll(listId);
|
||||
} catch (e) {
|
||||
// Said here, as moveItemTo does, or a removal fails in silence (inside the full-screen player
|
||||
@@ -538,7 +549,8 @@ function PlaylistDetail() {
|
||||
[video.id],
|
||||
now.items.map((v) => v.id),
|
||||
);
|
||||
if (back.keys.length) return api.addToPlaylist(listId, video.id, back.positions[0]);
|
||||
if (back.keys.length)
|
||||
return addPlaylistItem(qc, listRef(listId), video.id, back.positions[0]);
|
||||
})
|
||||
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
|
||||
.finally(() => {
|
||||
@@ -566,7 +578,7 @@ function PlaylistDetail() {
|
||||
try {
|
||||
// ONE request. The two-call version (add there, remove here) leaves the video in BOTH lists
|
||||
// whenever the second call fails — see the endpoint's docstring.
|
||||
await api.moveToPlaylist(selectedId, video.id, target.id);
|
||||
await movePlaylistItem(qc, listRef(selectedId), video.id, target);
|
||||
// Remembered only on SUCCESS: a shortcut that points at a move which failed would be a
|
||||
// shortcut to failing again.
|
||||
setLastMoveTargetId(target.id);
|
||||
|
||||
Reference in New Issue
Block a user