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
+38 -9
View File
@@ -22,14 +22,26 @@ import {
type LucideIcon,
} from "lucide-react";
import { YouTubeIcon } from "../../lib/brandIcons";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../../lib/api";
import {
api,
HttpError,
refusalReason,
type FeedFilters,
type Video,
type VideoStatus,
} from "../../lib/api";
import i18n from "../../i18n";
import { notifyActionError } from "../../lib/actionErrors";
import { announceVideoState, type NoticeVideo } from "../../features/video/stateNotice";
import {
resetVideoState,
writeVideoState,
writeWatchLater,
} from "../../features/video/videoWrites";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import { useDebounced } from "../../lib/useDebounced";
import { patchCachedProgress, patchRelated, progressFlushes } from "../../lib/progressCache";
import { patchCachedProgress, progressFlushes } from "../../lib/progressCache";
import {
buildSort,
feedSortOptions,
@@ -124,6 +136,15 @@ const withoutWrite = <T,>(o: Overrides<T>, id: string, seq: number): Overrides<T
// How many live-search results one fetch asks for; `Load more` pulls another batch of the same size.
const YT_COUNTS = [20, 40, 60, 100] as const;
// The live-search refusals a retry cannot answer: the day's cap, turned off on this instance, the
// shared budget spent (ours, or YouTube's own) — tomorrow or the admin does.
const SEARCH_FINAL_REASONS = new Set([
"search_daily_limit",
"search_disabled",
"quota_spent",
"quota",
]);
export default function Feed({
filters,
setFilters,
@@ -358,7 +379,7 @@ export default function Feed({
(id: string, status: VideoStatus, video?: NoticeVideo) => {
const seq = ++writeSeq.current;
setOverrides((o) => ({ ...o, [id]: { value: status, seq } }));
const write = api.setState(id, status);
const write = writeVideoState(qc, id, status);
trackSessionWrite(write);
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
@@ -373,7 +394,6 @@ export default function Feed({
// storm (PERF-2). The next filter change / remount refetches fresh from the server.
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
patchRelated(qc, id, { status });
// The video the player is on may be none of this list's (a related one): it names itself.
const v = video ?? loadedRef.current.find((x) => x.id === id);
announceVideoState(id, status, v, () => onStateRef.current(id, "new"));
@@ -401,8 +421,7 @@ export default function Feed({
const before = loadedRef.current.find((v) => v.id === id)?.position_seconds;
patchCachedProgress(qc, new Map([[id, 0]]));
resets.current.set(id, seq);
api
.clearState(id)
resetVideoState(qc, id)
// Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => {
answered.current.add(seq);
@@ -423,7 +442,7 @@ export default function Feed({
(id: string, saved: boolean) => {
const seq = ++writeSeq.current;
setSavedOverrides((o) => ({ ...o, [id]: { value: saved, seq } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
writeWatchLater(qc, id, saved)
.then(() => {
answered.current.add(seq);
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -491,6 +510,9 @@ export default function Feed({
: ytQuery.isError
? t("feed.yt.error")
: null;
// A refusal a retry cannot answer (the day's cap, turned off, the shared budget): Load more —
// the same request — is not offered after it.
const ytFinal = SEARCH_FINAL_REASONS.has(refusalReason(ytQuery.error) ?? "");
// The backend reports which source served the search; scrape spends no search quota, so we
// drop the quota warning + "uses quota" wording in that mode.
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
@@ -577,7 +599,7 @@ export default function Feed({
{ytQuery.isLoading ? (
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
) : ytError ? (
) : ytError && !ytItems.length ? (
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
) : ytItems.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
@@ -594,7 +616,7 @@ export default function Feed({
isFetching={false}
fetchNextPage={() => {}}
/>
{ytQuery.hasNextPage && (
{ytQuery.hasNextPage && !ytFinal && (
<div className="text-center pt-4">
<Button
variant="outline"
@@ -611,6 +633,13 @@ export default function Feed({
</Button>
</div>
)}
{/* A page that failed after others loaded: they stay, and the failure is said under
them (Load more above is the retry, when there is one). */}
{ytError && (
<StateMessage tone="error" compact className="mt-3">
{ytError}
</StateMessage>
)}
</>
)}