fix(player): a related video played is tracked, and its list is honest about its state

A related video played in place is the player's item now: its title and its own watched /
playlist / download controls, its progress checkpointed and resumed, marked watched at its end —
and its end does not step the queue. Back returns to the opened video, where it was left.

The related list:
- its marks announce through the shared notice (the Notification Center finds them), join the
  host's player session (the feed refetches on close), and stay per write, so an earlier answer no
  longer undoes a later mark still on its way;
- every host's state write patches the cached related lists, which are never refetched;
- kept for the session (gcTime), a retry asks for the page that failed, a list read so far with
  nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names
  itself on hover.

Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons
are left out.

Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide
moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the
top layer; the help sheet lists the list keys over a layer that holds a list.
This commit is contained in:
2026-10-25 00:32:28 +02:00
parent d53729a63d
commit b5045165c0
30 changed files with 1024 additions and 207 deletions
+4
View File
@@ -20,6 +20,10 @@ export interface Me {
google_enabled: boolean;
plex_enabled: boolean;
search_source: "scrape" | "api";
/** Whether live YouTube search / related videos are on: an admin turns either off with a daily
* limit of 0, and what would only be refused is then left out. */
search_enabled: boolean;
related_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
+13
View File
@@ -167,4 +167,17 @@ describe("watch state and progress", () => {
});
expect(opts).toEqual({ quiet: true });
});
it("a related list is quiet too, and sends its region and cursor", () => {
void feedApi.related("a/b", "hu", "CUR1");
const [url, , opts] = vi.mocked(req).mock.lastCall!;
const u = new URL(url as string, "http://x");
expect(u.pathname).toBe("/api/search/related/a%2Fb");
expect(Object.fromEntries(u.searchParams)).toEqual({ region: "hu", cursor: "CUR1" });
expect(opts).toEqual({ quiet: true });
void feedApi.related("abc", "topical", null);
const first = new URL(vi.mocked(req).mock.lastCall![0] as string, "http://x");
expect(Object.fromEntries(first.searchParams)).toEqual({ region: "topical" });
});
});
+19 -8
View File
@@ -472,22 +472,33 @@ export function useActiveScopes(): KeyScope[] {
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
// there is exactly one answer to "what can I press here" instead of one per player.
const helpOpen = createStore(false);
// Whether the sheet was opened OVER a layer that owns the keyboard (a player, a dialog). The page-level
// keys are quiet there, so the sheet must not list them — and it cannot ask the layer stack itself
// once it is showing, because its own Modal is a layer too. So the answer is taken at the moment of
// opening, before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
const helpOverLayer = createStore(false);
// What the sheet was opened OVER: the page, a layer that owns the keyboard (a player, a dialog), or
// such a layer with a list inside it (the player's related videos). The page-level keys are quiet over
// a layer, so the sheet must not list them — except the list keys, which walk a list inside the
// topmost layer (see `dispatch`'s `inTopLayer`). The sheet cannot ask the layer stack itself once it
// is showing, because its own Modal is a layer too, so the answer is taken at the moment of opening,
// before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
export type HelpOver = "page" | "layer" | "layerWithList";
const helpOverLayer = createStore<HelpOver>("page");
// Whether the topmost layer holds a list the keyboard walks. The lists are listNav's, which imports
// this module and so cannot be imported back: it hands the question in when it loads.
let listInTopLayer: () => boolean = () => false;
export const setListInTopLayerProbe = (probe: () => boolean): void => {
listInTopLayer = probe;
};
export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => {
const next = !helpOpen.get();
if (next) helpOverLayer.set(anyBlockingLayer());
if (next)
helpOverLayer.set(!anyBlockingLayer() ? "page" : listInTopLayer() ? "layerWithList" : "layer");
helpOpen.set(next);
};
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.useValue();
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.useValue();
/** What the sheet was opened over (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): HelpOver => helpOverLayer.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).
+2 -1
View File
@@ -61,7 +61,8 @@ export function topBlockingLayerRoot(): HTMLElement | null {
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
* page-level keys (`s`, the `g` chords, list navigation) stay quiet then: they act on the page
* underneath, which the user cannot see or reach while such a layer is up. */
* underneath, which the user cannot see or reach while such a layer is up. A list INSIDE the
* topmost layer is the exception: it is walked (`topBlockingLayerRoot`, listNav). */
export function anyBlockingLayer(): boolean {
return stack.some((id) => !passive.has(id));
}
+45
View File
@@ -457,6 +457,51 @@ describe("useListNav · the roving tabindex and a removed item", () => {
},
);
});
// The same inside a layer (the player's related videos): a row marked from its menu leaves and the
// focus falls to <body>, where the layer's quiet rule left `j` doing nothing.
it("goes on from the slot of a removed item inside the topmost layer too", () => {
function Removable() {
const [ids, setIds] = useState([0, 1, 2, 3]);
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true });
return (
<div data-testid="layer">
<div ref={ref}>
{ids.map((id, i) => (
<div key={id} {...navItemAttrs(i)} data-testid={`r${id}`}>
<button
data-testid={`r${id}-hide`}
onClick={() => setIds((xs) => xs.filter((x) => x !== id))}
>
hide
</button>
</div>
))}
</div>
</div>
);
}
withLayout(
(_, n) => ({ left: 0, top: n * 100 }),
() => {
render(<Removable />);
const layer = pushLayer({ root: () => screen.getByTestId("layer") });
try {
act(() => screen.getByTestId("r1-hide").focus());
press("j");
expect(focused()).toBe("r2");
act(() => screen.getByTestId("r2-hide").focus());
fireEvent.click(screen.getByTestId("r2-hide"));
expect(document.activeElement).toBe(document.body);
press("j");
expect(focused()).toBe("r3");
} finally {
removeLayer(layer);
}
},
);
});
});
describe("useListNav · leaving the list, and naming the item", () => {
+15 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
import { dispatch, useKeyScope } from "./keymap";
import { dispatch, setListInTopLayerProbe, useKeyScope } from "./keymap";
import { anyBlockingLayer, topBlockingLayerRoot } from "./layerStack";
import { scrollParent } from "./listPaging";
import { focusIsUnclaimed } from "./useFocusKeeper";
@@ -101,6 +101,12 @@ function current(): { list: NavList; index: number; item: HTMLElement } | null {
* move between the items, so a surface that also binds them (the player's seek) leaves them alone. */
export const focusInNavList = (): boolean => current() !== null;
// The help sheet lists the list keys over a layer only when this says there is a list to walk in it.
setListInTopLayerProbe(() => {
const layer = topBlockingLayerRoot();
return !!layer && [...lists.keys()].some((el) => layer.contains(el));
});
/** The mounted items that belong to `el` itself (not to a list nested inside it), in index order. */
function ownItems(el: HTMLElement): HTMLElement[] {
return [...el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
@@ -335,10 +341,16 @@ function onKey(e: KeyboardEvent): void {
if (move(dx, dy)) ev.preventDefault();
};
// The list keys act on the page, so an open layer silences them — unless the focus is in a list
// INSIDE the topmost layer: that list is what the user is in (the player's related videos).
// INSIDE the topmost layer: that list is what the user is in (the player's related videos). Or was,
// a moment ago: a row marked from its menu leaves the list and the focus falls to <body>, and `j`
// goes on from its slot (`slotOfRemoved`) as it does on the page.
const layer = topBlockingLayerRoot();
const cur = current();
const inTopLayer = !!layer && !!cur && layer.contains(cur.item);
const inTopLayer =
!!layer &&
(cur
? layer.contains(cur.item)
: focusIsUnclaimed(document.activeElement) && !!last && layer.contains(last.list.el));
dispatch(
e,
"list",
@@ -186,7 +186,7 @@ describe("patchCachedProgress", () => {
expect(after.pages[0]?.items[0]?.position_seconds).toBe(830);
});
it("patches a related list too — a row played from it resumes where it was left", () => {
it("patches a related list too — its rows open the same player", () => {
const qc = new QueryClient();
const key = qk.related("SRC", "topical");
qc.setQueryData(key, { pages: [{ items: [video()], next_cursor: null }], pageParams: [null] });
+18
View File
@@ -84,3 +84,21 @@ export function patchCachedProgress(qc: QueryClient, positions: Map<string, numb
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;
});
}