import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Bookmark, BookmarkX, Check, Download, Eye, EyeOff, Filter, FilterX, 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; /** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel * page (the view is that channel already), in a live YouTube search and on Home. */ channelFilter?: { ids: string[]; set: (ids: string[]) => 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(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, channelFilter } = 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: , onSelect: () => onOpen(v, null), }, ...(inProgress ? [ { id: "restart", label: t("card.restartTitle"), icon: , onSelect: () => onOpen(v, 0), }, ] : []), ] : []), { id: "channel", label: t("card.menu.openChannel"), icon: , onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")), }, // The channel's videos in this feed, or the channel out of the filter again: channels OR // together, so adding one widens a filter that already names others (as "Show in feed" does). ...(channelFilter ? [ channelFilter.ids.includes(v.channel_id) ? { id: "channel-filter", label: t("card.menu.channelFilterRemove"), icon: , onSelect: () => channelFilter.set(channelFilter.ids.filter((id) => id !== v.channel_id)), } : { id: "channel-filter", label: t("card.menu.channelFilterAdd"), icon: , onSelect: () => channelFilter.set([...channelFilter.ids, v.channel_id]), }, ] : []), { id: "watched", label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"), icon: watched ? : , separatorBefore: true, onSelect: () => onState(v.id, watched ? "new" : "watched"), }, { id: "save", label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"), icon: v.saved ? : , onSelect: () => onToggleSave(v.id, !v.saved), }, ...(addTo ? [ { id: "playlist", label: t("playlists.addToPlaylist"), icon: , onSelect: addTo, }, ] : []), { id: "download", label: t("downloads.menu.download"), note: dlState, icon: , onSelect: () => setFollowUp({ kind: "download", video: v }), }, ...(details ? [ { id: "details", label: t("card.info"), 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 ? : , separatorBefore: true, onSelect: () => onState(v.id, hidden ? "new" : "hidden"), }, ...(onResetState && isResettable(v) ? [ { id: "reset", label: t("card.resetState"), icon: , onSelect: () => onResetState(v.id), }, ] : []), ...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []), ]; }; const panel = followUp && setFollowUp(null)} />; return { items, panel }; }