Files
siftlode/frontend/src/features/player/RelatedVideos.tsx
T

231 lines
8.3 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import { CircleCheck } from "lucide-react";
import {
api,
HttpError,
type RelatedRegion,
type RelatedResponse,
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 { notify } from "../../lib/notifications";
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 VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu";
/** 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";
type Cached = InfiniteData<RelatedResponse, string | null>;
/** Patch one video in every related list held — both regions', and another opened video's. */
function patchRelated(
qc: ReturnType<typeof useQueryClient>,
id: string,
change: Partial<Video>,
): void {
qc.setQueriesData<Cached>({ queryKey: qk.related() }, (old) =>
old
? {
...old,
pages: old.pages.map((p) => ({
...p,
items: p.items.map((v) => (v.id === id ? { ...v, ...change } : v)),
})),
}
: old,
);
}
/** 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,
}: {
videoId: string;
onPlay: (v: Video, startAt: number) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [region, setRegion] = usePref(RELATED_REGION_PREF, "topical", RELATED_REGION_OPTIONS);
// Kept for the session: 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,
retry: false,
});
// A mark not yet confirmed: the row leaves (or its bookmark flips) at once, and comes back if the
// server refuses it.
const [pending, setPending] = useState<Record<string, Partial<Video>>>({});
const settle = (id: string) =>
setPending((p) => Object.fromEntries(Object.entries(p).filter(([k]) => k !== id)));
const onState = (id: string, status: VideoStatus) => {
const v = rows.find((x) => x.id === id);
setPending((p) => ({ ...p, [id]: { ...p[id], status } }));
api
.setState(id, status)
.then(() => {
patchRelated(qc, id, { status });
settle(id);
// Stale-mark the feed, as its own marks do: the video may be in it too.
void qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" });
if (status === "hidden" || status === "watched") {
const named = status === "hidden" ? "feed.hiddenNamed" : "feed.markedWatchedNamed";
notify({
message: v?.title
? t(named, { title: v.title })
: t(status === "hidden" ? "feed.hidden" : "feed.markedWatched"),
action: {
label: t(status === "hidden" ? "feed.undo" : "feed.unwatch"),
onClick: () => onState(id, "new"),
},
});
}
})
.catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState"));
settle(id);
});
};
const onToggleSave = (id: string, saved: boolean) => {
setPending((p) => ({ ...p, [id]: { ...p[id], saved } }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
patchRelated(qc, id, { saved });
settle(id);
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(id);
});
};
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[v.id] ? { ...v, ...pending[v.id] } : 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;
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={() => void query.refetch()}>
{error}
</StateMessage>
) : !rows.length && !query.hasNextPage ? (
<StateMessage compact>{t("player.related.empty")}</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) && (
<CircleCheck
className="w-3.5 h-3.5 shrink-0 text-success"
aria-label={t("player.related.followed")}
role="img"
/>
)
}
/>
))}
</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={() => void query.fetchNextPage(nextPageOnClick(query.isFetchingNextPage))}
>
{error}
</StateMessage>
)}
</>
)}
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onOpen: open }} />
</section>
);
}