Files
siftlode/frontend/src/components/usePlexCardActions.tsx
T
peter 89e65ca331 feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.

A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
2026-09-27 02:12:54 +02:00

128 lines
5.3 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Info, ListPlus, Play, SkipBack, Tv2, Undo2 } from "lucide-react";
import { type PlexCard } from "../lib/api";
import { pinPlex, useComfortPin } from "./comfortPin";
import { useCopyItems } from "./copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import { type ActionMenuItem } from "./ui/ActionMenu";
// E28 C1 — 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 {
onOpen: (c: PlexCard) => void;
/** The media-info 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 show opens its drill-down; a movie or an episode plays. */
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. */
export const plexStarted = (c: PlexCard): boolean =>
isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0;
/** `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<PlexAddTarget | null>(null);
// 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. */
const items = (
c: PlexCard,
{ play = true, pin: withPin = true }: { play?: boolean; pin?: boolean } = {},
): ActionMenuItem[] => {
const { onOpen, onInfo, onToggleWatched, onRestart } = handlers;
const playable = isPlexPlayable(c);
const watched = c.status === "watched";
const started = plexStarted(c);
return [
...(play
? [
{
id: "play",
label: !playable ? t("plex.openShow") : started ? t("card.continue") : t("plex.play"),
icon: playable ? <Play className={icon} /> : <Tv2 className={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: <SkipBack className={icon} />,
onSelect: () => onRestart(c),
},
]
: []),
]
: []),
...(playable && onToggleWatched
? [
{
id: "watched",
label: watched ? t("plex.markUnwatched") : t("plex.markWatched"),
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
separatorBefore: true,
onSelect: () => onToggleWatched(c),
},
]
: []),
...(playable
? [
{
id: "playlist",
label: addLabel(c),
icon: <ListPlus className={icon} />,
separatorBefore: !onToggleWatched,
onSelect: () => add(c),
},
]
: []),
...(playable && onInfo
? [
{
id: "details",
label: t("plex.info.title"),
icon: <Info className={icon} />,
separatorBefore: true,
onSelect: () => onInfo(c),
},
]
: []),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !(playable && onInfo) })),
...(withPin ? pin.item(pinPlex(c), c.title) : []),
];
};
const panel = adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />;
return { items, add, panel };
}
export type PlexCardActions = ReturnType<typeof usePlexCardActions>;