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
+23 -13
View File
@@ -3,11 +3,18 @@ import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { CircleCheck } from "lucide-react";
import { api, HttpError, type RelatedRegion, type Video, type VideoStatus } from "../../lib/api";
import {
api,
HttpError,
refusalReason,
type RelatedRegion,
type Video,
type VideoStatus,
} from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { nextPageOnClick } from "../../lib/nextPage";
import { usePref, type UsePrefOptions } from "../../lib/usePref";
import { patchRelated } from "../../lib/progressCache";
import { useVideoMarks } from "../../lib/progressCache";
import { notifyActionError } from "../../lib/actionErrors";
import { Button } from "../../components/ui/Button";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
@@ -18,6 +25,7 @@ import Tooltip from "../../components/ui/Tooltip";
import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */
@@ -33,9 +41,7 @@ const isSeen = (v: Video) => v.status === "watched" || v.status === "hidden";
/** The refusals a retry cannot answer: the day's cap (tomorrow does), the feature turned off on
* this instance and the shared budget spent (the admin, or tomorrow). */
const FINAL_REASONS = new Set(["related_daily_limit", "related_disabled", "quota"]);
const isFinal = (e: unknown) =>
e instanceof HttpError &&
FINAL_REASONS.has((e.detailData as { reason?: string } | undefined)?.reason ?? "");
const isFinal = (e: unknown) => FINAL_REASONS.has(refusalReason(e) ?? "");
// A write not yet confirmed: its change shows at once, and leaves when the server answers.
type Pending = { seq: number; id: string; change: Partial<Video> };
@@ -49,7 +55,8 @@ export function RelatedVideos({
onWrite,
}: {
videoId: string;
onPlay: (v: Video, startAt: number) => void;
/** `startAt` null resumes the video where it was left; a number (Restart's 0) starts there. */
onPlay: (v: Video, startAt: number | null) => void;
/** Each write this list makes, for the host's player session (see PlayerModal `onWrite`). */
onWrite?: (write: Promise<unknown>) => void;
}) {
@@ -83,11 +90,10 @@ export function RelatedVideos({
const onState = (id: string, status: VideoStatus) => {
const v = rows.find((x) => x.id === id);
const seq = begin(id, { status });
const write = api.setState(id, status);
const write = writeVideoState(qc, id, status);
onWrite?.(write);
write
.then(() => {
patchRelated(qc, id, { status });
settle(seq);
// Stale-mark the feed, as its own marks do: the video may be in it too.
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
@@ -101,11 +107,10 @@ export function RelatedVideos({
const onToggleSave = (id: string, saved: boolean) => {
const seq = begin(id, { saved });
const write = saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id);
const write = writeWatchLater(qc, id, saved);
onWrite?.(write);
write
.then(() => {
patchRelated(qc, id, { saved });
settle(seq);
void qc.invalidateQueries({ queryKey: qk.playlists() });
void qc.invalidateQueries({ queryKey: qk.playlist() });
@@ -119,12 +124,15 @@ export function RelatedVideos({
});
};
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? v.position_seconds);
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? null);
const menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined;
// What this session confirmed about a row (here or anywhere else), then what is on its way.
const marks = useVideoMarks();
const rows = (query.data?.pages ?? [])
.flatMap((p) => p.items)
.map((v) => (marks[v.id] ? { ...v, ...marks[v.id] } : v))
.map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
.filter((v) => !isSeen(v));
const followed = new Set(
@@ -139,7 +147,8 @@ export function RelatedVideos({
: null;
// A refused page is the one to try again: with pages loaded, a refetch started over from the first
// (another scrape and another slot) instead of asking for the page that failed.
const retry = isFinal(query.error)
const final = isFinal(query.error);
const retry = final
? undefined
: query.data?.pages.length
? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))
@@ -203,7 +212,8 @@ export function RelatedVideos({
/>
))}
</NavGrid>
{query.hasNextPage && (
{/* Show more is the same request a Retry makes: refused for good, neither is offered. */}
{query.hasNextPage && !final && (
<div className="mt-3 flex justify-center">
<Button
variant="outline"