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:
@@ -56,6 +56,7 @@ import DownloadButton from "../downloads/DownloadButton";
|
||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
|
||||
import { api, type Video, type VideoStatus } from "../../lib/api";
|
||||
import { markedValue, useVideoMarks } from "../../lib/progressCache";
|
||||
import type { YTPlayer } from "../../lib/youtube";
|
||||
import {
|
||||
channelYouTubeUrl,
|
||||
@@ -543,19 +544,30 @@ export default function PlayerModal({
|
||||
});
|
||||
|
||||
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
|
||||
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
|
||||
// is ignored rather than displayed — that is the enforcement, and it has to be in code because
|
||||
// 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 `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 [marks, setMarks] = useState<Record<string, VideoStatus>>({});
|
||||
const watched = (marks[subject.id] ?? subject.status) === "watched";
|
||||
// (and server) via onState. Each mark is kept under the id it was MADE for, and that has to be in
|
||||
// code because 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 late write is
|
||||
// recorded for the item it belongs to (shown again if the user steps back to it) and never
|
||||
// painted on the next one; unkeyed it landed last and painted the NEXT video as watched, and a
|
||||
// corrective click then sent onState(next, "new") for an item that was already new.
|
||||
// The newest word wins: this toggle's own mark, or one the session confirmed elsewhere since the
|
||||
// player opened (the related row's own menu, a notice's Undo — `useVideoMarks`), else the item as
|
||||
// it was opened. The toggle alone missed those, and its first click re-sent the same status.
|
||||
const [marks, setMarks] = useState<Record<string, { status: VideoStatus; at: number }>>({});
|
||||
const [openedAt] = useState(() => Date.now());
|
||||
const sessionMarks = useVideoMarks();
|
||||
const own = marks[subject.id];
|
||||
const confirmed = markedValue(sessionMarks, subject.id, "status");
|
||||
const elsewhere = confirmed && confirmed.at >= openedAt ? confirmed : undefined;
|
||||
const status =
|
||||
own && (!elsewhere || own.at >= elsewhere.at)
|
||||
? own.status
|
||||
: (elsewhere?.value ?? subject.status);
|
||||
const watched = status === "watched";
|
||||
const setWatched = (on: boolean) => {
|
||||
const next = on ? "watched" : "new";
|
||||
setMarks((m) => ({ ...m, [subject.id]: next }));
|
||||
setMarks((m) => ({ ...m, [subject.id]: { status: next, at: Date.now() } }));
|
||||
onState(subject.id, next, subject);
|
||||
};
|
||||
|
||||
@@ -727,8 +739,11 @@ export default function PlayerModal({
|
||||
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.
|
||||
// Unless a description link put another video in the player meanwhile (`navigated`): then the
|
||||
// pick goes back to this one — the seek moved the linked video, and the pick did nothing.
|
||||
if (v.id === subject.id) {
|
||||
if (startAt != null) seekTo(startAt);
|
||||
if (navigated) loadVideo(subject.id, startAt);
|
||||
else if (startAt != null) seekTo(startAt);
|
||||
return;
|
||||
}
|
||||
setRelated({ video: v, startAt });
|
||||
|
||||
Reference in New Issue
Block a user