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