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
+27 -50
View File
@@ -25,9 +25,11 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { notify, resolveVideo } from "../../lib/notifications";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { useDebounced } from "../../lib/useDebounced";
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache";
import {
buildSort,
feedSortOptions,
@@ -178,6 +180,8 @@ export default function Feed({
null,
);
const qc = useQueryClient();
// Live search turned off on this instance: the empty feed offers no search that would be refused.
const searchOn = useMe().search_enabled;
// What changed state while the player was open, and the writes still in flight: closing it
// refetches the list by its filters once those land. Marked watched (by hand, or by the player at
@@ -339,18 +343,23 @@ export default function Feed({
// The toast's Undo re-enters onState, a forward reference: through this ref (set below).
const onStateRef = useRef<(id: string, status: VideoStatus) => void>(() => {});
// A write made while the player is open joins its session: the list refetches once the player
// has closed and every such write has landed (the effect above). The player's own related list
// writes through this too — its marks reach videos this list may hold.
const trackSessionWrite = useCallback((write: Promise<unknown>) => {
const session = playerSession.current;
if (!session.open) return;
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}, []);
const onState = useCallback(
(id: string, status: VideoStatus) => {
(id: string, status: VideoStatus, video?: NoticeVideo) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
const session = playerSession.current;
if (session.open) {
session.changed = true;
session.pending.add(write);
const settled = () => void session.pending.delete(write);
write.then(settled, settled);
}
trackSessionWrite(write);
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
@@ -364,44 +373,10 @@ export default function Feed({
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "watched") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: {
label: i18n.t("feed.unwatch"),
onClick: () => onStateRef.current(id, "new"),
},
meta: {
kind: "video-watched",
videoId: id,
title: v?.title ?? i18n.t("feed.thisVideo"),
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "new") {
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
resolveVideo(id);
}
patchRelated(qc, id, { status });
// The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id);
announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
})
.catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card
@@ -411,7 +386,7 @@ export default function Feed({
setOverrides((o) => withoutWrite(o, id, seq));
});
},
[qc],
[qc, trackSessionWrite],
);
const onResetState = useCallback(
@@ -647,6 +622,7 @@ export default function Feed({
queue={ytPlayerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}
@@ -928,7 +904,7 @@ export default function Feed({
</Button>
</div>
)}
{filters.q.trim() && (
{filters.q.trim() && searchOn && (
<Button
className="mt-4"
icon={<YouTubeIcon />}
@@ -971,6 +947,7 @@ export default function Feed({
queue={playerQueue}
onClose={closePlayer}
onState={onState}
onWrite={trackSessionWrite}
/>
</Suspense>
)}