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.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user