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