feat(player): a video's related videos, on demand beside it
The player shows what YouTube lists beside the video, without what the user has watched or hidden. Wide, a Related tab beside the description; stacked, a button beside the info opens them in a drawer. A row plays in the same player (Back returns) and the list stays the opened video's. A switch picks on-topic or Hungarian-trending lists, kept on the account. Each related row says whether the user follows its channel; the row is the video card's narrow form with its whole menu. VideoMenu moves to features/video, where the player can reach it.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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). */
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user