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.
128 lines
5.3 KiB
TypeScript
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>;
|