- A key landing on a side panel's tab steps into the card it peeks open, not onto the hidden tab. - The Channels cards keep their status / pager row at the list's end while the band is folded. - A menu row's note shows on an enabled row too: the download state under 'Download…', not a title. - A removal chip hands the focus to its neighbour before it goes, for every caller (the feed's and Plex's active filters); the hidden-filter cue's own copy is gone. - Back to a spot within a page's end spacer lands at the page's end, not its top. - A running push / revert is tracked per list: another list's push is no longer refused unseen.
191 lines
6.9 KiB
TypeScript
191 lines
6.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Bookmark,
|
|
BookmarkX,
|
|
Check,
|
|
Download,
|
|
Eye,
|
|
EyeOff,
|
|
Info,
|
|
ListPlus,
|
|
Play,
|
|
RotateCcw,
|
|
SkipBack,
|
|
Tv,
|
|
Undo2,
|
|
} from "lucide-react";
|
|
|
|
import { api, type Me, 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";
|
|
|
|
// E28 C1 — 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;
|
|
}
|
|
|
|
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 qc = useQueryClient();
|
|
const { openChannel } = useNavigationActions();
|
|
const pin = useComfortPin(open);
|
|
const copy = useCopyItems();
|
|
// Downloads spend server disk and need a real identity: the demo account has none (as the
|
|
// player's DownloadButton).
|
|
const canDownload = !qc.getQueryData<Me>(qk.me())?.is_demo;
|
|
const index = useQuery({
|
|
queryKey: qk.downloadIndex(),
|
|
queryFn: api.downloadIndex,
|
|
enabled: open && canDownload,
|
|
staleTime: 10000,
|
|
});
|
|
const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(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 } = 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 (E28 review J4) — and
|
|
// not a `title`, which a key or a finger never shows (round 2, F5).
|
|
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: <Play className={icon} />,
|
|
onSelect: () => onOpen(v, null),
|
|
},
|
|
...(inProgress
|
|
? [
|
|
{
|
|
id: "restart",
|
|
label: t("card.restartTitle"),
|
|
icon: <SkipBack className={icon} />,
|
|
onSelect: () => onOpen(v, 0),
|
|
},
|
|
]
|
|
: []),
|
|
]
|
|
: []),
|
|
{
|
|
id: "channel",
|
|
label: t("card.menu.openChannel"),
|
|
icon: <Tv className={icon} />,
|
|
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
|
|
},
|
|
{
|
|
id: "watched",
|
|
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
|
|
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
|
|
separatorBefore: true,
|
|
onSelect: () => onState(v.id, watched ? "new" : "watched"),
|
|
},
|
|
{
|
|
id: "save",
|
|
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
|
|
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
|
onSelect: () => onToggleSave(v.id, !v.saved),
|
|
},
|
|
...(addTo
|
|
? [
|
|
{
|
|
id: "playlist",
|
|
label: t("playlists.addToPlaylist"),
|
|
icon: <ListPlus className={icon} />,
|
|
onSelect: addTo,
|
|
},
|
|
]
|
|
: []),
|
|
...(canDownload
|
|
? [
|
|
{
|
|
id: "download",
|
|
label: t("downloads.menu.download"),
|
|
note: dlState,
|
|
icon: <Download className={icon} />,
|
|
onSelect: () => setFollowUp({ kind: "download", video: v }),
|
|
},
|
|
]
|
|
: []),
|
|
...(details
|
|
? [
|
|
{
|
|
id: "details",
|
|
label: t("card.info"),
|
|
icon: <Info className={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 ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
|
separatorBefore: true,
|
|
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
|
},
|
|
...(onResetState && isResettable(v)
|
|
? [
|
|
{
|
|
id: "reset",
|
|
label: t("card.resetState"),
|
|
icon: <RotateCcw className={icon} />,
|
|
onSelect: () => onResetState(v.id),
|
|
},
|
|
]
|
|
: []),
|
|
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
|
|
];
|
|
};
|
|
|
|
const panel = followUp && <VideoFollowUp state={followUp} onClose={() => setFollowUp(null)} />;
|
|
return { items, panel };
|
|
}
|