Files
siftlode/frontend/src/components/useVideoActions.tsx
T
peter 75ae810866 feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
2026-09-26 01:23:44 +02:00

187 lines
6.6 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];
const dlLabel =
dl === "done"
? t("downloads.button.downloaded")
: dl === "queued" || dl === "running" || dl === "paused"
? t("downloads.button.queued")
: t("downloads.button.label");
// 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: dlLabel,
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 };
}