Files
siftlode/frontend/src/features/plexCards/usePlexCardActions.tsx
T
peter 0bc1024287 fix(player): marks are weighed when answered, lit and pressed hold under the pointer, and Restart is one glyph
The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier.

A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
2026-10-25 13:51:26 +01:00

170 lines
7.2 KiB
TypeScript

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<PlexAddTarget | null>(null);
const [collecting, setCollecting] = useState<PlexCollectionTarget | null>(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: <Play 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: <RotateCcw 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),
},
]
: []),
// 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: <Layers className={icon} />,
separatorBefore: !playable,
onSelect: () => setCollecting({ ratingKey: c.id, anchor }),
},
]
: []),
...(onInfo
? [
{
id: "details",
label: isShow ? t("plex.openShow") : t("plex.info.title"),
icon: isShow ? <Tv2 className={icon} /> : <Info className={icon} />,
separatorBefore: true,
onSelect: () => onInfo(c),
},
]
: []),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
...(withPin ? pin.item(pinPlex(c), c.title) : []),
];
};
const panel = (
<>
{adding && <PlexPlaylistAdd target={adding} onClose={() => setAdding(null)} />}
{collecting && (
<PlexCollectionAddAt target={collecting} onClose={() => setCollecting(null)} />
)}
</>
);
return { items, add, panel };
}
export type PlexCardActions = ReturnType<typeof usePlexCardActions>;