238 lines
9.7 KiB
TypeScript
238 lines
9.7 KiB
TypeScript
import { useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { CircleCheck } from "lucide-react";
|
|
|
|
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 { markedRows, stampFetch, useVideoMarks } from "../../lib/progressCache";
|
|
import { notifyActionError } from "../../lib/actionErrors";
|
|
import { Button } from "../../components/ui/Button";
|
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
|
import { useContextMenu } from "../../components/ui/useContextMenu";
|
|
import NavGrid from "../../components/ui/NavGrid";
|
|
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";
|
|
import { msg } from "../../lib/notifications";
|
|
|
|
/** 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). */
|
|
const RELATED_REGION_PREF = "relatedRegion";
|
|
const RELATED_REGION_OPTIONS: UsePrefOptions<RelatedRegion> = {
|
|
parse: (raw) => (raw === "hu" ? "hu" : "topical"),
|
|
};
|
|
|
|
// The list leaves out what the user has seen (the server does, as it fetches); a video marked here
|
|
// leaves it the same way, at once.
|
|
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) => 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> };
|
|
|
|
/** The videos YouTube shows beside `videoId`, without the ones the user has watched or hidden. A
|
|
* click plays one in this player (`onPlay`); the list stays the opened video's while it does. Its
|
|
* rows carry the feed's whole menu. */
|
|
export function RelatedVideos({
|
|
videoId,
|
|
onPlay,
|
|
onWrite,
|
|
}: {
|
|
videoId: string;
|
|
/** `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;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const [region, setRegion] = usePref(RELATED_REGION_PREF, "topical", RELATED_REGION_OPTIONS);
|
|
// Kept for the session — never refetched, never collected while the app is open: the list
|
|
// unmounts whenever the drawer closes or the tab changes, and a refetch is another scrape and
|
|
// another slot of the day's cap.
|
|
const query = useInfiniteQuery({
|
|
queryKey: qk.related(videoId, region),
|
|
queryFn: ({ pageParam }) => stampFetch(() => api.related(videoId, region, pageParam)),
|
|
initialPageParam: null as string | null,
|
|
getNextPageParam: (last) => (last.has_more ? last.next_cursor : undefined),
|
|
staleTime: Infinity,
|
|
gcTime: Infinity,
|
|
retry: false,
|
|
});
|
|
// The marks on their way, one entry per WRITE: a row leaves (or its bookmark flips) at once, and
|
|
// comes back if the server refuses that write. Kept per video, the first answer to land cleared
|
|
// the video's whole entry, and a save then a hide put the hidden row back until the hide landed.
|
|
const [pending, setPending] = useState<Pending[]>([]);
|
|
const seqRef = useRef(0);
|
|
const begin = (id: string, change: Partial<Video>) => {
|
|
const seq = ++seqRef.current;
|
|
setPending((p) => [...p, { seq, id, change }]);
|
|
return seq;
|
|
};
|
|
const settle = (seq: number) => setPending((p) => p.filter((w) => w.seq !== seq));
|
|
|
|
const onState = (id: string, status: VideoStatus) => {
|
|
const v = rows.find((x) => x.id === id);
|
|
const seq = begin(id, { status });
|
|
const write = writeVideoState(qc, id, status);
|
|
onWrite?.(write);
|
|
write
|
|
.then(() => {
|
|
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" });
|
|
announceVideoState(id, status, v, () => onState(id, "new"));
|
|
})
|
|
.catch((e: unknown) => {
|
|
notifyActionError(e, msg("errors.writeFailed.videoState"));
|
|
settle(seq);
|
|
});
|
|
};
|
|
|
|
const onToggleSave = (id: string, saved: boolean) => {
|
|
const seq = begin(id, { saved });
|
|
const write = writeWatchLater(qc, id, saved);
|
|
onWrite?.(write);
|
|
write
|
|
.then(() => {
|
|
settle(seq);
|
|
void qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
void qc.invalidateQueries({ queryKey: qk.playlist() });
|
|
})
|
|
.catch((e: unknown) => {
|
|
notifyActionError(
|
|
e,
|
|
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
|
);
|
|
settle(seq);
|
|
});
|
|
};
|
|
|
|
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 since its page was read (here or anywhere else), then
|
|
// what is on its way.
|
|
const marks = useVideoMarks();
|
|
const rows = markedRows(query.data?.pages, marks)
|
|
.map((v) => pending.reduce((acc, w) => (w.id === v.id ? { ...acc, ...w.change } : acc), v))
|
|
.filter((v) => !isSeen(v));
|
|
const followed = new Set(
|
|
(query.data?.pages ?? []).flatMap((p) => p.items.filter((v) => v.followed).map((v) => v.id)),
|
|
);
|
|
|
|
const error =
|
|
query.error instanceof HttpError
|
|
? query.error.detail || t("player.related.error")
|
|
: query.isError
|
|
? t("player.related.error")
|
|
: 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 final = isFinal(query.error);
|
|
const retry = final
|
|
? undefined
|
|
: query.data?.pages.length
|
|
? () => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))
|
|
: () => void query.refetch();
|
|
|
|
return (
|
|
<section data-testid="player-related" aria-label={t("player.related.title")}>
|
|
<SegmentedToggle
|
|
options={[
|
|
{ value: "topical", label: t("player.related.topical") },
|
|
{ value: "hu", label: t("player.related.hu") },
|
|
]}
|
|
value={region}
|
|
onChange={setRegion}
|
|
ariaLabel={t("player.related.region")}
|
|
fullWidth
|
|
className="mb-3"
|
|
/>
|
|
{query.isPending ? (
|
|
<LoadingState shape="list" />
|
|
) : error && !rows.length ? (
|
|
<StateMessage tone="error" compact onRetry={retry}>
|
|
{error}
|
|
</StateMessage>
|
|
) : !rows.length && !query.hasNextPage ? (
|
|
<StateMessage compact>{t("player.related.empty")}</StateMessage>
|
|
) : (
|
|
<>
|
|
{/* What was read so far was all seen (or marked here since): the list goes on, it is not
|
|
empty — said, so Show more is not a lone button under nothing. */}
|
|
{!rows.length && <StateMessage compact>{t("player.related.emptySoFar")}</StateMessage>}
|
|
<NavGrid className="flex flex-col gap-1.5">
|
|
{rows.map((v, i) => (
|
|
<VideoCard
|
|
key={v.id}
|
|
navIndex={i}
|
|
video={v}
|
|
view="rows"
|
|
narrow
|
|
width={0}
|
|
onState={onState}
|
|
onToggleSave={onToggleSave}
|
|
onOpen={open}
|
|
menuProps={menu.triggerProps}
|
|
menuButtonProps={menu.buttonProps}
|
|
menuOpen={openId === v.id}
|
|
channelMark={
|
|
followed.has(v.id) && (
|
|
// A bare mark means nothing to a mouse: it names itself on hover, whatever
|
|
// the hints setting, since the caption is all it has.
|
|
<Tooltip hint={t("player.related.followed")} always>
|
|
<CircleCheck
|
|
className="w-3.5 h-3.5 shrink-0 text-success"
|
|
aria-label={t("player.related.followed")}
|
|
role="img"
|
|
/>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
/>
|
|
))}
|
|
</NavGrid>
|
|
{/* 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"
|
|
size="sm"
|
|
disabled={query.isFetchingNextPage}
|
|
onClick={() => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))}
|
|
>
|
|
{query.isFetchingNextPage ? t("player.related.loading") : t("player.related.more")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{error && rows.length > 0 && (
|
|
<StateMessage tone="error" compact onRetry={retry}>
|
|
{error}
|
|
</StateMessage>
|
|
)}
|
|
</>
|
|
)}
|
|
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onOpen: open }} />
|
|
</section>
|
|
);
|
|
}
|