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:
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user