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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user