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.
183 lines
6.5 KiB
TypeScript
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 };
|
|
}
|