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
+115 -47
View File
@@ -85,6 +85,7 @@ import { SURFACE } from "../../components/ui/surfaces";
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
import { SectionLabel } from "../../components/ui/Headings";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { useMe } from "../../lib/useMe";
import Modal from "../../components/ui/Modal";
import Tabs from "../../components/ui/Tabs";
import { useTabPanel } from "../../components/ui/tabPanel";
@@ -168,6 +169,7 @@ export default function PlayerModal({
startIndex,
onClose,
onState,
onWrite,
onRemoveFromList,
}: {
video: Video;
@@ -180,7 +182,12 @@ export default function PlayerModal({
queue?: Video[];
startIndex?: number;
onClose: () => void;
onState: (id: string, status: VideoStatus) => void;
/** The player's own marks (its watched toggle, the auto-mark at the end), for the item it is on —
* named by `video`, which may be none of the host's (a related video played in place). */
onState: (id: string, status: VideoStatus, video?: Video) => void;
/** A write the related list made itself, for a host that refetches once the player closes and its
* writes have landed (the feed's player session). */
onWrite?: (write: Promise<unknown>) => void;
/** Remove the playing video from the list it was opened from (the Playlists module passes its own
* `removeItem`; the feed passes nothing — there is no list to remove it from). Returns whether it
* actually happened, because the CONSEQUENCE is the player's: its launch queue is frozen, so it
@@ -236,6 +243,19 @@ export default function PlayerModal({
// startAt only applies to the item we opened with; advanced items resume their own pos.
const resumeAt =
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
// A related video played in place (the owner's pick) is the item it now is: its progress is
// checkpointed, it is marked watched at its end, and its own watched / playlist / download
// controls show — while the queue stays where it was, and Back returns to the opened item. It is
// the SUBJECT of the player from here down; `active` stays the queue's item, which the related
// list, the counter and prev/next belong to. A queue step leaves it.
const [related, setRelated] = useState<{ video: Video; startAt: number } | null>(null);
const [relatedOf, setRelatedOf] = useState(active.id);
if (relatedOf !== active.id) {
setRelatedOf(active.id);
setRelated(null);
}
const subject = related?.video ?? active;
const subjectResume = related ? related.startAt : resumeAt;
const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<YTPlayer | null>(null);
// Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
@@ -251,11 +271,16 @@ export default function PlayerModal({
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The player's place on the layer stack (registered below), for what must defer to a layer above.
const layerIdRef = useRef<number | null>(null);
// What the player does on its own (turning ready, the pointer leaving the video, the window
// regaining focus, a wheel over it, leaving fullscreen) takes the focus back only when it may have
// slipped into the iframe or off the card. Taking it off a control the viewer tabbed to restarted
// their Tab from the first button.
// their Tab from the first button. Nor while a layer that owns the keyboard is open above the
// player (the related drawer, a dialog): its focus is outside the card by design, and the window
// regaining focus pulled it onto the card behind the scrim.
const reclaimFocus = useCallback(() => {
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
const card = cardRef.current;
const active = document.activeElement;
if (
@@ -351,7 +376,7 @@ export default function PlayerModal({
// currently-playing id may differ from the active item.
const [currentVideoId, setCurrentVideoId] = useState(active.id);
const currentIdRef = useRef(active.id);
const navigated = currentVideoId !== active.id;
const navigated = currentVideoId !== subject.id;
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
// the video, which is its preview; another video starts without one.
const [clip, setClip] = useState<ClipRange | null>(null);
@@ -371,7 +396,7 @@ export default function PlayerModal({
// The picture the crop frame is drawn on. The iframe never tells its video's size: a short is
// 9:16, anything else is taken as the player's 16:9, and the cut fits the frame to the real picture
// (downloads/clip) — so a 4:3 video's frame drawn over its bars loses only what lies on the bars.
const picture = !navigated && active.is_short ? 9 / 16 : 16 / 9;
const picture = !navigated && subject.is_short ? 9 / 16 : 16 / 9;
// The docked clip panel's height, measured (it wraps with the window and the text size): the video
// above it ends there.
const clipDockRef = useRef<HTMLDivElement>(null);
@@ -488,7 +513,7 @@ export default function PlayerModal({
// Title/author of a navigated-to video, read from the player (free, no API call).
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
const heading = navigated ? (liveData?.title ?? t("player.loading")) : active.title;
const heading = navigated ? (liveData?.title ?? t("player.loading")) : subject.title;
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
@@ -499,9 +524,13 @@ export default function PlayerModal({
const loadVideo = useStableCallback((id: string, start: number | null) => {
const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return;
// Explicit start wins; otherwise resume the active item, start others at 0.
// Explicit start wins; otherwise resume the item the player is on, start others at 0.
const startSeconds =
start != null ? start : id === active.id ? (sessionPosRef.current.get(id) ?? resumeAt) : 0;
start != null
? start
: id === subject.id
? (sessionPosRef.current.get(id) ?? subjectResume)
: 0;
setPlayerError(null);
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id;
@@ -515,18 +544,18 @@ export default function PlayerModal({
// one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
// after the commit that already advanced `active`, and near the end of a clip it auto-marks that
// item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
// item's, so the toggle falls back to `active.status` and no ordering has to be argued about.
// item's, so the toggle falls back to `subject.status` and no ordering has to be argued about.
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
// onState(next, "new") for an item that was already new.
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
id: active.id,
value: active.status,
});
const watched = (status.id === active.id ? status.value : active.status) === "watched";
const watched = (status.id === subject.id ? status.value : subject.status) === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus({ id: active.id, value: next });
onState(active.id, next);
setStatus({ id: subject.id, value: next });
onState(subject.id, next, subject);
};
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
@@ -537,9 +566,9 @@ export default function PlayerModal({
// The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and
// immune to the late writer this block cannot see (see that comment).
const [syncedTo, setSyncedTo] = useState(active.id);
if (syncedTo !== active.id) {
setSyncedTo(active.id);
setCurrentVideoId(active.id);
if (syncedTo !== subject.id) {
setSyncedTo(subject.id);
setCurrentVideoId(subject.id);
}
// `currentIdRef` must NOT move into the block above, and the reason is EFFECT ORDERING, which the
@@ -553,8 +582,8 @@ export default function PlayerModal({
// frozen launch queue's stale position — which is the exact bug the session-position map exists
// to prevent (748 instead of 830, in the test's numbers).
useEffect(() => {
currentIdRef.current = active.id;
}, [active.id]);
currentIdRef.current = subject.id;
}, [subject.id]);
const seekTo = useStableCallback((seconds: number) => {
const p = playerRef.current;
@@ -646,6 +675,8 @@ export default function PlayerModal({
if (clipOpenRef.current) return;
const { autoMode: a, loopMode: l } = modeRef.current;
if (l === "one") return replayCurrent();
// A related video's end is not the queue's: the list beside it is where the next one is picked.
if (related) return;
const { queue: q, index: i } = navRef.current;
if (!q || q.length === 0) return;
const wrap = l === "all";
@@ -681,10 +712,19 @@ export default function PlayerModal({
// list in place, to try the next one (the owner's pick).
const [panelTab, setPanelTab] = useState<"description" | "related">("description");
const [relatedOpen, setRelatedOpen] = useState(false);
// The drawer is the stacked layout's: going wide while it is open, the list moves into the panel's
// tab instead. Left open, it reopened unasked when the window narrowed again.
if (wide && relatedOpen) {
setRelatedOpen(false);
setPanelTab("related");
}
const panelTabs = useTabPanel();
// Turned off on this instance (a daily limit of 0): no tab, no button — nothing that could only
// be refused.
const relatedOn = useMe().related_enabled;
const playRelated = (v: Video, startAt: number) => {
setRelatedOpen(false);
loadVideo(v.id, startAt);
setRelated({ video: v, startAt });
};
const descPop = useAnchoredPopover({
open: showDesc,
@@ -712,7 +752,6 @@ export default function PlayerModal({
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or an anchored menu) takes Escape first and this player defers. Its root lets
// the keyboard walk a list inside it (the related videos).
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer({ root: () => dialogRef.current });
layerIdRef.current = id;
@@ -764,6 +803,15 @@ export default function PlayerModal({
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
return;
}
// A layer that owns the keyboard above the player (the related drawer, a menu, a dialog) has the
// keys: on its ✕ the arrows changed the volume and seeked the video behind it. Escape is the
// exception, and `close` decides for itself which layer it closes.
if (
e.key !== "Escape" &&
layerIdRef.current != null &&
anyBlockingLayerAbove(layerIdRef.current)
)
return;
dispatch(e, "ytPlayer", {
clip: () => {
if (clip) closeClip();
@@ -838,8 +886,8 @@ export default function PlayerModal({
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
// the refs passed in. See lib/playerModalHooks.
useYtProgress({
activeId: active.id,
resumeAt,
activeId: subject.id,
resumeAt: subjectResume,
mountRef,
playerRef,
currentIdRef,
@@ -1051,9 +1099,9 @@ export default function PlayerModal({
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
{!navigated && <AddToPlaylist videoId={subject.id} variant="overlay" />}
{!navigated && (
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
<DownloadButton videoId={subject.id} title={subject.title} variant="overlay" />
)}
{/* Clip mode here: the clip panel takes this bar's place. */}
{playerError == null && (
@@ -1266,7 +1314,7 @@ export default function PlayerModal({
<Info />
</IconButton>
)}
{!wide && (
{!wide && relatedOn && (
<IconButton
variant="ghost"
onClick={() => setRelatedOpen(true)}
@@ -1286,14 +1334,16 @@ export default function PlayerModal({
maxWidth="max-w-sm"
initialFocus="close"
>
<RelatedVideos videoId={active.id} onPlay={playRelated} />
<RelatedVideos videoId={active.id} onPlay={playRelated} onWrite={onWrite} />
</Modal>
)}
{navigated && (
{(navigated || related) && (
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
// One step back: from a linked video to the one it was linked from, from a
// related video to the opened item.
onClick={() => (navigated ? loadVideo(subject.id, null) : setRelated(null))}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
>
@@ -1326,8 +1376,8 @@ export default function PlayerModal({
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
src={subject.channel_thumbnail}
fallback={subject.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
@@ -1353,19 +1403,19 @@ export default function PlayerModal({
className={`flex items-center gap-1.5 min-w-0 ${wide ? "flex-1" : "shrink-0"}`}
>
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
channelId={subject.channel_id}
title={subject.channel_title}
onOpenInApp={() => {
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
openChannel(subject.channel_id, subject.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
/>
<IconLink
href={active.channel_url}
href={subject.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
@@ -1379,27 +1429,27 @@ export default function PlayerModal({
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{active.view_count != null && (
{subject.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
count: subject.view_count,
formattedCount: formatCompact(subject.view_count, lang),
})}
</span>
)}
<span>
{metaSep}
{relativeTime(active.published_at, lang, t)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
{relativeTime(subject.published_at, lang, t)}
{subject.published_at && ` · ${fullDate(subject.published_at)}`}
</span>
{active.duration_seconds != null && (
{subject.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(active.duration_seconds)}
{formatDuration(subject.duration_seconds)}
</span>
)}
{active.live_status === "was_live" && (
{subject.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
@@ -1452,10 +1502,10 @@ export default function PlayerModal({
>
<Maximize />
</IconButton>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<AddToPlaylist videoId={subject.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
videoId={subject.id}
title={subject.title}
variant="outline"
size="md"
/>
@@ -1472,7 +1522,7 @@ export default function PlayerModal({
<Scissors />
</IconButton>
)}
{onRemoveFromList && (
{onRemoveFromList && !related && (
<IconButton
variant="outline-danger"
size="md"
@@ -1498,8 +1548,22 @@ export default function PlayerModal({
</div>
)}
</div>
{wide && (
{wide && !relatedOn && (
<section className="mt-5" data-testid="player-description">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
clamp
{...descriptionProps}
/>
</section>
)}
{wide && relatedOn && (
<div className="mt-5">
{/* The class replaces Tabs' own, so it keeps the row's layout and gap with
its tighter margin. */}
<Tabs
tabs={[
{ id: "description", label: t("player.description") },
@@ -1509,7 +1573,7 @@ export default function PlayerModal({
onChange={(id) => setPanelTab(id === "related" ? "related" : "description")}
idBase={panelTabs.idBase}
label={t("player.panelTabs")}
className="mb-3"
className="flex flex-wrap gap-1 mb-3"
/>
<div {...panelTabs.panelProps(panelTab)}>
{panelTab === "description" ? (
@@ -1523,7 +1587,11 @@ export default function PlayerModal({
) : (
// Out into the panel's padding: in 20rem, every rem is the title's.
<div className="-mx-3">
<RelatedVideos videoId={active.id} onPlay={playRelated} />
<RelatedVideos
videoId={active.id}
onPlay={playRelated}
onWrite={onWrite}
/>
</div>
)}
</div>