import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, Info, Layers, ListPlus, Play, RotateCcw, Tv2, Undo2 } from "lucide-react"; import { type PlexCard } from "../../lib/api"; import { qk } from "../../lib/queryKeys"; import PlexCollectionAddAt, { type PlexCollectionTarget } from "./PlexCollectionAdd"; import { pinPlex, useComfortPin } from "../comfort/comfortPin"; import { useCopyItems } from "../../components/ui/copyItems"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; // A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the // long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail // lists the same actions in the same order. The card keeps its state (watched, progress) at rest and // the everyday toggles (watched, add to playlist) on hover; details, copy and the pin live here. export interface PlexActionHandlers { /** Play it: a show, the episode it would play (the surface resolves it). */ onOpen: (c: PlexCard) => void; /** The media-info page, or a show's own page. Omitted where a surface has none (the episode * lists). */ onInfo?: (c: PlexCard) => void; onToggleWatched?: (c: PlexCard) => void; /** Play from the start instead of the resume point (C5). Where given, a started title lists it * under Play — on touch, the only way to it (the card's hover pair is mouse-only). */ onRestart?: (c: PlexCard) => void; } const icon = "w-4 h-4"; /** The "add to playlist" row and button: an episode says which it adds, as the season page does. */ export const plexAddLabelKey = (c: PlexCard): string => c.type === "episode" ? "plex.playlist.addEpisode" : "plex.playlistAdd.manage"; /** A leaf: a movie or an episode, what the watched toggle, the playlist add and a resume point * belong to. A show plays too, through the episode it resolves to. */ const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show"; /** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is * stored at 0, so it never counts. A show has one when an episode does (`resumable`, the episode its * click plays): part watched with its next episode untouched, Continue and Restart would be the same * thing, so it is not "started" here — its in-progress badge says how far it has got. */ export const plexStarted = (c: PlexCard): boolean => isPlexPlayable(c) ? c.status !== "watched" && (c.position_seconds ?? 0) > 0 : c.resumable === true; /** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the * item builder, `add` for the card's own hover button, and the dialog to render beside the menu * (one per list, so a card does not mount a modal of its own). */ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean) { const { t } = useTranslation(); const pin = useComfortPin(open); const copy = useCopyItems(); const addLabel = (c: PlexCard) => t(plexAddLabelKey(c)); const [adding, setAdding] = useState(null); const [collecting, setCollecting] = useState(null); // Collections are shared and written to Plex itself, so only an admin edits them (as on the title's // own page). const qc = useQueryClient(); const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; // A whole show needs its episode keys, which only the show page has ("Add whole show" there), so // only a leaf is added from a card. const add = (c: PlexCard) => setAdding({ kind: "single", ratingKey: c.id, title: c.title }); /** `play: false` where the host menu leads with its own Play row (Home's shelf resolves `next`); * `pin: false` where the host carries its own shelf rows or the object is not a pin kind; * `anchor`: the element the menu came from, which "Add to collection…" opens its panel beside — * without one the row is left out rather than opening the panel at a corner. Required, so every * host menu decides: an optional one let a menu that has an anchor drop the row silently. */ const items = ( c: PlexCard, { play = true, pin: withPin = true, anchor, }: { play?: boolean; pin?: boolean; anchor: Element | null }, ): ActionMenuItem[] => { const { onOpen, onInfo, onToggleWatched, onRestart } = handlers; const playable = isPlexPlayable(c); const isShow = c.type === "show"; const watched = c.status === "watched"; const started = plexStarted(c); return [ ...(play ? [ { id: "play", label: started ? t("card.continue") : t("plex.play"), icon: , onSelect: () => onOpen(c), }, // The same words and glyph as the video card's menu (useVideoActions). ...(started && onRestart ? [ { id: "restart", label: t("card.restartTitle"), icon: , onSelect: () => onRestart(c), }, ] : []), ] : []), ...(playable && onToggleWatched ? [ { id: "watched", label: watched ? t("plex.markUnwatched") : t("plex.markWatched"), icon: watched ? : , separatorBefore: true, onSelect: () => onToggleWatched(c), }, ] : []), ...(playable ? [ { id: "playlist", label: addLabel(c), icon: , separatorBefore: !onToggleWatched, onSelect: () => add(c), }, ] : []), // A movie or a show can be a collection member; an episode cannot. ...(isAdmin && anchor && c.type !== "episode" ? [ { id: "collection", label: t("plex.collectionAdd.row"), icon: , separatorBefore: !playable, onSelect: () => setCollecting({ ratingKey: c.id, anchor }), }, ] : []), ...(onInfo ? [ { id: "details", label: isShow ? t("plex.openShow") : t("plex.info.title"), icon: isShow ? : , separatorBefore: true, onSelect: () => onInfo(c), }, ] : []), ...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })), ...(withPin ? pin.item(pinPlex(c), c.title) : []), ]; }; const panel = ( <> {adding && setAdding(null)} />} {collecting && ( setCollecting(null)} /> )} ); return { items, add, panel }; } export type PlexCardActions = ReturnType;