The queue row (previous, count, next, auto-advance, loop) wrapped onto three lines in the narrow side panel; glyph-only, it fits one. Mark watched joins the action icons as a toggle (check / double check). Every control keeps its full name and shortcut as its tooltip and accessible name; the docked bar keeps its labels. The related switch moves up to the toolbar rung (12px), and its second side reads just "Trending".
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 { 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";
|
|
|
|
/** 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 }) => 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, t("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,
|
|
t(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 (here or anywhere else), then what is on its way.
|
|
const marks = useVideoMarks();
|
|
const rows = (query.data?.pages ?? [])
|
|
.flatMap((p) => p.items)
|
|
.map((v) => (marks[v.id] ? { ...v, ...marks[v.id] } : v))
|
|
.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>
|
|
);
|
|
}
|