231 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|