fix(player): marks are weighed when answered, lit and pressed hold under the pointer, and Restart is one glyph
The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier. A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
This commit is contained in:
@@ -163,6 +163,24 @@ const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
||||
// the line wraps and the meta, or the actions after it, move down instead of squeezing.
|
||||
const META_MIN = "min-w-48 flex-1";
|
||||
|
||||
// The watched toggle's own word on an item: what it last asked for, how many of its writes are still
|
||||
// on the way, and when the last of them was answered (Infinity until then).
|
||||
type OwnMark = { status: VideoStatus; seq: number; pending: number; at: number };
|
||||
|
||||
// One of the toggle's writes was answered: stamped once none is left on the way; the latest refused,
|
||||
// the toggle's word is dropped and the item shows what the server holds.
|
||||
function settled(marks: Record<string, OwnMark>, id: string, seq: number, ok: boolean) {
|
||||
const own = marks[id];
|
||||
if (!own) return marks;
|
||||
if (!ok && own.seq === seq) {
|
||||
const rest = { ...marks };
|
||||
delete rest[id];
|
||||
return rest;
|
||||
}
|
||||
const pending = own.pending - 1;
|
||||
return { ...marks, [id]: { ...own, pending, at: pending > 0 ? Infinity : Date.now() } };
|
||||
}
|
||||
|
||||
export default function PlayerModal({
|
||||
video,
|
||||
startAt,
|
||||
@@ -184,8 +202,9 @@ export default function PlayerModal({
|
||||
startIndex?: number;
|
||||
onClose: () => 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;
|
||||
* named by `video`, which may be none of the host's (a related video played in place). Returns
|
||||
* the write: the toggle holds its own word until the server has answered, and drops a refused one. */
|
||||
onState: (id: string, status: VideoStatus, video?: Video) => Promise<unknown>;
|
||||
/** 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;
|
||||
@@ -554,7 +573,12 @@ export default function PlayerModal({
|
||||
// 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 }>>({});
|
||||
// Both are weighed when the server answered them: the toggle's own writes come back as session
|
||||
// marks too, stamped at their answer, so a click weighed at its own time lost to the late answer
|
||||
// of the click before it. While any of its writes is on the way the toggle's word holds; a refused
|
||||
// one is dropped, so the toggle does not keep showing what the server turned down.
|
||||
const [marks, setMarks] = useState<Record<string, OwnMark>>({});
|
||||
const ownSeq = useRef(0);
|
||||
const [openedAt] = useState(() => Date.now());
|
||||
const sessionMarks = useVideoMarks();
|
||||
const own = marks[subject.id];
|
||||
@@ -566,9 +590,17 @@ export default function PlayerModal({
|
||||
: (elsewhere?.value ?? subject.status);
|
||||
const watched = status === "watched";
|
||||
const setWatched = (on: boolean) => {
|
||||
const id = subject.id;
|
||||
const next = on ? "watched" : "new";
|
||||
setMarks((m) => ({ ...m, [subject.id]: { status: next, at: Date.now() } }));
|
||||
onState(subject.id, next, subject);
|
||||
const seq = ++ownSeq.current;
|
||||
setMarks((m) => ({
|
||||
...m,
|
||||
[id]: { status: next, seq, pending: (m[id]?.pending ?? 0) + 1, at: Infinity },
|
||||
}));
|
||||
Promise.resolve(onState(id, next, subject)).then(
|
||||
() => setMarks((m) => settled(m, id, seq, true)),
|
||||
() => setMarks((m) => settled(m, id, seq, false)),
|
||||
);
|
||||
};
|
||||
|
||||
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
|
||||
|
||||
Reference in New Issue
Block a user