fix(player): related marks survive, Restart restarts, and refused pages offer nothing to click

- Every state and Watch-later write goes through features/video/videoWrites,
  which marks the never-refetched lists (related lists, live searches) once
  the server has it; a contract test holds it. The Notification Center's
  undo, the feed's reset and the Watch-later toggles skipped it before.
- Confirmed marks are also kept apart (qk.videoMarks) and laid over the
  related rows: a Show more page landing later rebuilt them from the pages
  it read when it started, and dropped a mark made meanwhile.
- Restart on a related video starts at 0 (the session's stored position
  won before), and on the one already playing it seeks.
- The watched toggle remembers each video's mark: Back showed the opened
  video's launch-time state after a related one was marked.
- The help sheet leaves out the player's keys while a layer above it has
  them waiting.
- After a refusal a retry cannot answer, neither Show more (related) nor
  Load more (live search) is offered; a failed live-search page keeps the
  results already read and says why under them.
- The related cap message counts requests, as the cap does.
This commit is contained in:
2026-10-25 02:50:34 +02:00
parent ed51f5be7e
commit 4b5b66343d
26 changed files with 667 additions and 93 deletions
+2 -2
View File
@@ -6,10 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
import { HttpError, setUnauthorizedHandler } from "./api/core";
import { HttpError, refusalReason, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export { HttpError, setUnauthorizedHandler };
export { HttpError, refusalReason, setUnauthorizedHandler };
import { accountApi } from "./api/account";
export type * from "./api/account";
+6
View File
@@ -65,6 +65,12 @@ export class HttpError extends Error {
}
}
/** The structured refusal's reason (`{ code, reason }`), when the server gave one. */
export const refusalReason = (err: unknown): string | undefined =>
err instanceof HttpError
? (err.detailData as { reason?: string } | undefined)?.reason
: undefined;
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
+15 -1
View File
@@ -488,17 +488,31 @@ export const setListInTopLayerProbe = (probe: () => boolean): void => {
listInTopLayer = probe;
};
// 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
// 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 => {
if (probe) coveredProbes.set(scope, probe);
else coveredProbes.delete(scope);
};
const helpCovered = createStore<ReadonlySet<KeyScope>>(new Set());
export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => {
const next = !helpOpen.get();
if (next)
if (next) {
helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer");
helpCovered.set(new Set([...coveredProbes].filter(([, covered]) => covered()).map(([s]) => s)));
}
helpOpen.set(next);
};
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
/** What the sheet was opened over (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): HelpOver => helpOverLayer.useValue();
/** The surfaces a layer covered when the sheet opened (see `setScopeCoveredProbe`). */
export const useKeyboardHelpCovered = (): ReadonlySet<KeyScope> => helpCovered.useValue();
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
@@ -30,6 +30,7 @@ function mountPlayer(setPlayerError: (code: number | null) => void) {
useYtProgress({
activeId: VID,
resumeAt: 0,
exactStart: false,
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
@@ -72,6 +72,7 @@ function mountPlayer(qc: QueryClient, sessionPos: Map<string, number>) {
useYtProgress({
activeId: VID,
resumeAt: 748,
exactStart: false,
mountRef: { current: mount },
playerRef: createRef<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
+7 -1
View File
@@ -339,6 +339,7 @@ function playerTimes(p: YTPlayer | null): { cur: number; dur: number } | null {
export function useYtProgress({
activeId,
resumeAt,
exactStart,
mountRef,
playerRef,
currentIdRef,
@@ -355,6 +356,8 @@ export function useYtProgress({
}: {
activeId: string;
resumeAt: number;
// `resumeAt` is where the user asked to start, so it beats a position this session stored.
exactStart: boolean;
mountRef: RefObject<HTMLDivElement | null>;
playerRef: RefObject<YTPlayer | null>;
currentIdRef: RefObject<string>;
@@ -378,6 +381,7 @@ export function useYtProgress({
// the end of the effect).
const itemSnapshot = useEffectEvent(() => ({
resumeAt,
exactStart,
setWatched,
setPlayerError,
setLiveData,
@@ -392,6 +396,7 @@ export function useYtProgress({
const id = activeId;
const {
resumeAt,
exactStart,
setWatched,
setPlayerError,
setLiveData,
@@ -401,7 +406,8 @@ export function useYtProgress({
qc,
} = itemSnapshot();
// A position this session already stored for the item beats the queue's snapshot of it.
const startAt = sessionPosRef.current.get(id) ?? resumeAt;
// …unless the user asked for a start of their own (Restart on a related row).
const startAt = exactStart ? resumeAt : (sessionPosRef.current.get(id) ?? resumeAt);
autoMarkedRef.current = false;
setPlayerError(null);
+38
View File
@@ -0,0 +1,38 @@
// 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";
import { QueryClient } from "@tanstack/react-query";
import { markVideo, patchCachedProgress } from "./progressCache";
import { qk } from "./queryKeys";
import type { Video } from "./api";
const v = (id: string): Video =>
({ id, status: "new", saved: false, position_seconds: 0 }) as Video;
const pages = (...ids: string[]) => ({ pages: [{ items: ids.map(v) }], pageParams: [null] });
const items = (qc: QueryClient, key: readonly unknown[]) =>
(qc.getQueryData(key) as { pages: { items: Video[] }[] }).pages[0]!.items;
describe("markVideo", () => {
it("writes the mark into every live search and every related list", () => {
const qc = new QueryClient();
qc.setQueryData(qk.ytSearch("cats", 20), pages("X", "Y"));
qc.setQueryData(qk.related("A", "topical"), pages("X"));
markVideo(qc, "X", { status: "watched" });
expect(items(qc, qk.ytSearch("cats", 20)).map((r) => r.status)).toEqual(["watched", "new"]);
expect(items(qc, qk.related("A", "topical"))[0]!.status).toBe("watched");
});
it("keeps the mark apart from the lists, for the session", () => {
const qc = new QueryClient();
markVideo(qc, "X", { status: "hidden" });
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 },
});
// 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);
});
});
+58 -34
View File
@@ -1,4 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import type { Video } from "./api";
@@ -40,17 +40,67 @@ export const progressFlushes = (): Promise<unknown>[] => [...flushes];
export function patchCachedProgress(qc: QueryClient, positions: Map<string, number>): void {
if (positions.size === 0) return;
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
// search and every related list — their rows come from the same projection, carry the same
// per-user position, and open the same player, so leaving one out left the rewind in place on that
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
// units, a related list's a scrape and a slot of the day's cap.)
patchLists(qc, [qk.feed(), qk.playlist(), qk.ytSearch(), qk.related()], (v) => {
const pos = positions.get(v.id);
return pos === undefined || v.position_seconds === pos ? v : { ...v, position_seconds: pos };
});
for (const [id, pos] of positions) remember(qc, id, { position_seconds: pos });
}
/** Write one video's confirmed change (its status, its Watch-later mark) into the lists that are
* never refetched: every related list (both regions', every opened video's) and every live
* YouTube search — another scrape and a slot of the day's cap, or 100 quota units, each. A video
* marked anywhere else (a feed card, a playlist, the player, the Notification Center's undo) still
* showed there unmarked. Called by `features/video/videoWrites`, which every such write goes
* through (its contract test holds that). */
export function markVideo(qc: QueryClient, id: string, change: Partial<Video>): void {
patchLists(qc, [qk.ytSearch(), qk.related()], (v) => (v.id === id ? { ...v, ...change } : v));
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>>;
function remember(qc: QueryClient, id: string, change: Partial<Video>): void {
// 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 } }));
}
/** This session's confirmed marks, per video id (see `markVideo`). */
export function useVideoMarks(): Marks {
const qc = useQueryClient();
return useQuery({
queryKey: qk.videoMarks(),
// Nothing to fetch: the marks are what this session wrote, and the cache is where they live.
queryFn: () => qc.getQueryData<Marks>(qk.videoMarks()) ?? {},
initialData: {},
staleTime: Infinity,
gcTime: Infinity,
}).data;
}
/** 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. */
function patchLists(qc: QueryClient, keys: readonly QueryKey[], edit: (v: Video) => Video): void {
const patchList = (items: Video[]): Video[] => {
let changed = false;
const next = items.map((v) => {
const pos = positions.get(v.id);
if (pos === undefined || v.position_seconds === pos) return v;
changed = true;
return { ...v, position_seconds: pos };
const out = edit(v);
if (out !== v) changed = true;
return out;
});
// Keep the previous array identity when nothing moved, so an untouched list does not
// re-render every card behind the open player.
return changed ? next : items;
};
@@ -74,31 +124,5 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
return old;
};
// Prefix keys: every filter combination of the feed, every playlist detail, every live YouTube
// search and every related list — their rows come from the same projection, carry the same
// per-user position, and open the same player, so leaving one out left the rewind in place on that
// surface. (Patched, never invalidated: a refetch of an API-sourced search page spends 100 quota
// units, a related list's a scrape and a slot of the day's cap.)
qc.setQueriesData<Cached>({ queryKey: qk.feed() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.playlist() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.ytSearch() }, patch);
qc.setQueriesData<Cached>({ queryKey: qk.related() }, patch);
}
/** Write one video's change (its status, its Watch-later mark) into every cached related list —
* both regions', every opened video's. Those lists are never refetched (another scrape and a slot
* of the day's cap), and they leave out what the user has seen: a video marked anywhere else — a
* feed card, a playlist, the player itself — still showed in one opened again later. So every
* state write calls it once the server has the change. */
export function patchRelated(qc: QueryClient, id: string, change: Partial<Video>): void {
qc.setQueriesData<Cached>({ queryKey: qk.related() }, (old) => {
if (!old?.pages) return old;
let changed = false;
const pages = old.pages.map((page) => {
if (!isItemList(page) || !page.items.some((v) => v.id === id)) return page;
changed = true;
return { ...page, items: page.items.map((v) => (v.id === id ? { ...v, ...change } : v)) };
});
return changed ? { ...old, pages } : old;
});
for (const queryKey of keys) qc.setQueriesData<Cached>({ queryKey }, patch);
}
+2
View File
@@ -34,6 +34,8 @@ export const qk = {
videoDetail: (id: string) => ["video-detail", id] as const,
related: (id?: string, region?: string) =>
id !== undefined ? (["related", id, region] as const) : (["related"] as const),
// This session's confirmed marks per video (lib/progressCache `markVideo`): no fetch behind it.
videoMarks: () => ["video-marks"] as const,
// --- channels ---
channels: () => ["channels"] as const,