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:
2026-10-25 02:50:34 +02:00
parent ed51f5be7e
commit 4b5b66343d
26 changed files with 667 additions and 93 deletions
+27 -10
View File
@@ -65,7 +65,7 @@ import {
relativeTime,
} from "../../lib/format";
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../../lib/keymap";
import { dispatch, isActivationTarget, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history";
import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster";
@@ -248,14 +248,18 @@ export default function PlayerModal({
// 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);
// `startAt` null resumes it where it was left (this session's stored position first); a number is
// where the user asked it to start — Restart's 0 — and beats that position.
const [related, setRelated] = useState<{ video: Video; startAt: number | null } | 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 subjectResume = related
? (related.startAt ?? related.video.position_seconds ?? 0)
: 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
@@ -547,14 +551,11 @@ export default function PlayerModal({
// 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 === subject.id ? status.value : subject.status) === "watched";
const [marks, setMarks] = useState<Record<string, VideoStatus>>({});
const watched = (marks[subject.id] ?? subject.status) === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus({ id: subject.id, value: next });
setMarks((m) => ({ ...m, [subject.id]: next }));
onState(subject.id, next, subject);
};
@@ -722,8 +723,14 @@ export default function PlayerModal({
// 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) => {
const playRelated = (v: Video, startAt: number | null) => {
setRelatedOpen(false);
// The video already playing (its row stays in the list): a new player for the same id would
// never be built (one per video), so a Restart is a seek, and a plain pick just keeps playing.
if (v.id === subject.id) {
if (startAt != null) seekTo(startAt);
return;
}
setRelated({ video: v, startAt });
};
const descPop = useAnchoredPopover({
@@ -762,6 +769,15 @@ export default function PlayerModal({
}, []);
useKeyScope("ytPlayer");
// Its keys wait under a layer above it (the drawer, a dialog — see onPlayerKey), so the help sheet
// opened there must not list them.
useEffect(() => {
setScopeCoveredProbe(
"ytPlayer",
() => layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current),
);
return () => setScopeCoveredProbe("ytPlayer", null);
}, []);
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
// scope) so the help sheet lists them; only the behaviour behind each id is here.
@@ -888,6 +904,7 @@ export default function PlayerModal({
useYtProgress({
activeId: subject.id,
resumeAt: subjectResume,
exactStart: related?.startAt != null,
mountRef,
playerRef,
currentIdRef,