Files
siftlode/frontend/src/features/player/RelatedVideos.tsx
T
peter 620dd8178f feat(player): the side panel's list controls and watch toggle are glyphs on one line
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".
2026-10-25 02:43:01 +01:00

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>
);
}