A related video played in place is the player's item now: its title and its own watched / playlist / download controls, its progress checkpointed and resumed, marked watched at its end — and its end does not step the queue. Back returns to the opened video, where it was left. The related list: - its marks announce through the shared notice (the Notification Center finds them), join the host's player session (the feed refetches on close), and stay per write, so an earlier answer no longer undoes a later mark still on its way; - every host's state write patches the cached related lists, which are never refetched; - kept for the session (gcTime), a retry asks for the page that failed, a list read so far with nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names itself on hover. Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons are left out. Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the top layer; the help sheet lists the list keys over a layer that holds a list.
229 lines
9.4 KiB
TypeScript
229 lines
9.4 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, 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 { 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";
|
|
|
|
/** 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) =>
|
|
e instanceof HttpError &&
|
|
FINAL_REASONS.has((e.detailData as { reason?: string } | undefined)?.reason ?? "");
|
|
|
|
// 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;
|
|
onPlay: (v: Video, startAt: number) => 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 }) => 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 = api.setState(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" });
|
|
announceVideoState(id, status, v, () => onState(id, "new"));
|
|
})
|
|
.catch((e: unknown) => {
|
|
notifyActionError(e, t("errors.writeFailed.videoState"));
|
|
settle(seq);
|
|
});
|
|
};
|
|
|
|
const onToggleSave = (id: string, saved: boolean) => {
|
|
const seq = begin(id, { saved });
|
|
const write = saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id);
|
|
onWrite?.(write);
|
|
write
|
|
.then(() => {
|
|
patchRelated(qc, id, { saved });
|
|
settle(seq);
|
|
void qc.invalidateQueries({ queryKey: qk.playlists() });
|
|
void qc.invalidateQueries({ queryKey: qk.playlist() });
|
|
})
|
|
.catch((e: unknown) => {
|
|
notifyActionError(
|
|
e,
|
|
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
|
);
|
|
settle(seq);
|
|
});
|
|
};
|
|
|
|
const open = (v: Video, startAt?: number | null) => onPlay(v, startAt ?? v.position_seconds);
|
|
const menu = useContextMenu<Video>();
|
|
const openId = menu.open ? menu.target?.id : undefined;
|
|
|
|
const rows = (query.data?.pages ?? [])
|
|
.flatMap((p) => p.items)
|
|
.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 retry = isFinal(query.error)
|
|
? 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")}
|
|
size="sm"
|
|
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>
|
|
{query.hasNextPage && (
|
|
<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>
|
|
);
|
|
}
|