Files
siftlode/frontend/src/components/useVideoActions.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

183 lines
6.5 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import {
Bookmark,
BookmarkX,
Check,
Download,
Eye,
EyeOff,
Info,
ListPlus,
Play,
RotateCcw,
SkipBack,
Tv,
Undo2,
} from "lucide-react";
import { api, type Video, type VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { qk } from "../lib/queryKeys";
import { pinVideo, useComfortPin } from "./comfortPin";
import { useCopyItems } from "./copyItems";
import { useNavigationActions } from "./navigationContext";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
// and the Home shelves list the same actions in the same order. The card itself keeps only its
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
// download, details, copy, hide, reset, pin — lives here, and only here.
export interface VideoActionHandlers {
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Omitted where a surface cannot reset a video's state (Home's shelves). */
onResetState?: (id: string) => void;
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
onOpen?: (v: Video, startAt?: number | null) => void;
}
const icon = "w-4 h-4";
/** `open` is the host menu's own flag: the download state and the shelf are fetched only once a
* menu has been opened, since they only label a row. Returns the item builder and the follow-up
* panel to render beside the menu (one per list). */
export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
const { t } = useTranslation();
const { openChannel } = useNavigationActions();
const pin = useComfortPin(open);
const copy = useCopyItems();
const index = useQuery({
queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex,
enabled: open,
staleTime: 10000,
});
const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(null);
/** `pin: false` where the host menu carries its own shelf rows (Home's rails). */
const items = (
v: Video,
anchor: Element | null,
{ pin: withPin = true }: { pin?: boolean } = {},
): ActionMenuItem[] => {
const { onState, onToggleSave, onResetState, onOpen } = handlers;
const watched = v.status === "watched";
const hidden = v.status === "hidden";
const inProgress = v.position_seconds > 0 && !watched;
const dl = index.data?.[v.id];
// The row opens the download dialog, so it says so; the download's state is the line under its
// label — "Downloaded" read as a status, not as a row that opens something — and
// not a `title`, which a key or a finger never shows.
const dlState =
dl === "done"
? t("downloads.button.downloaded")
: dl === "queued" || dl === "running" || dl === "paused"
? t("downloads.button.queued")
: undefined;
// The two panels open beside the object; a menu opened with no element to anchor to (none of
// the triggers does that today) leaves them out rather than opening them at the corner.
const at = (kind: "playlist" | "details") =>
anchor ? () => setFollowUp({ kind, video: v, anchor }) : null;
const addTo = at("playlist");
const details = at("details");
return [
...(onOpen
? [
{
id: "play",
label: inProgress ? t("card.continue") : t("card.play"),
icon: <Play className={icon} />,
onSelect: () => onOpen(v, null),
},
...(inProgress
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className={icon} />,
onSelect: () => onOpen(v, 0),
},
]
: []),
]
: []),
{
id: "channel",
label: t("card.menu.openChannel"),
icon: <Tv className={icon} />,
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
},
{
id: "watched",
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
separatorBefore: true,
onSelect: () => onState(v.id, watched ? "new" : "watched"),
},
{
id: "save",
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
onSelect: () => onToggleSave(v.id, !v.saved),
},
...(addTo
? [
{
id: "playlist",
label: t("playlists.addToPlaylist"),
icon: <ListPlus className={icon} />,
onSelect: addTo,
},
]
: []),
{
id: "download",
label: t("downloads.menu.download"),
note: dlState,
icon: <Download className={icon} />,
onSelect: () => setFollowUp({ kind: "download", video: v }),
},
...(details
? [
{
id: "details",
label: t("card.info"),
icon: <Info className={icon} />,
separatorBefore: true,
onSelect: details,
},
]
: []),
...copy(v.title, v.watch_url).map((it, i) =>
i === 0 && !details ? { ...it, separatorBefore: true } : it,
),
{
id: "hide",
label: hidden ? t("card.unhide") : t("card.hide"),
icon: hidden ? <Eye className={icon} /> : <EyeOff className={icon} />,
separatorBefore: true,
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
},
...(onResetState && isResettable(v)
? [
{
id: "reset",
label: t("card.resetState"),
icon: <RotateCcw className={icon} />,
onSelect: () => onResetState(v.id),
},
]
: []),
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
];
};
const panel = followUp && <VideoFollowUp state={followUp} onClose={() => setFollowUp(null)} />;
return { items, panel };
}