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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user