1import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
2import {
3 mediaReferenceFileName,
4 type MediaReference,
5} from "@t3tools/client-runtime/media-reference";
6import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
7import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import {
9 AuthFilesystemReadScope,
10 type AssetResource,
11 type ContextMenuItem,
12 type EnvironmentId,
13 sessionGrantsScope,
14 type SessionGrantInput,
15} from "@t3tools/contracts";
16import * as Option from "effect/Option";
17import { AsyncResult } from "effect/reactivity";
18import { useCallback, useRef, useState, type ReactElement } from "react";
19
20import { useFileContextMenu } from "../../fileContextMenu";
21import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
22import { readLocalApi } from "../../localApi";
23import { appAtomRegistry } from "../../rpc/atomRegistry";
24import { assetEnvironment } from "../../state/assets";
25import { useEnvironmentQuery } from "../../state/query";
26import { environmentSession, readPreparedConnection } from "../../state/session";
27import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
28import { stackedThreadToast, toastManager } from "../ui/toast";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
30import { downloadMedia, readMediaPng } from "./mediaContent";
31
32export interface MediaActionSource {
33 readonly kind: "image" | "video";
34 readonly name: string;
35 readonly src: string | null;
36 readonly reference?: MediaReference;
37 readonly asset?: { readonly environmentId: EnvironmentId; readonly resource: AssetResource };
38 readonly onOpenFile?: () => void;
39}
40
41function mediaFileName(source: MediaActionSource): string {
42 return (
43 (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind
44 );
45}
46
47/** An explicit action may ask the server while its grant is still unresolved. */
48function allowsHostMedia(session: SessionGrantInput | null) {
49 return session === null || sessionGrantsScope(session, AuthFilesystemReadScope);
50}
51
52function canReadHostMedia(environmentId: EnvironmentId | null): boolean {
53 if (environmentId === null) return true;
54 const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));
55 return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result)));
56}
57
58/** Explicit byte operations get fresh capabilities without replacing a player's active source. */
59export function useMediaActions(source: MediaActionSource) {
60 const hostEnvironmentId =
61 source.asset &&
62 (source.asset.resource._tag === "workspace-file" ||
63 source.asset.resource._tag === "media-file" ||
64 source.asset.resource._tag === "draft-workspace-file")
65 ? source.asset.environmentId
66 : null;
67 const fileSession = useEnvironmentQuery(
68 hostEnvironmentId === null ? null : environmentSession.sessionStateAtom(hostEnvironmentId),
69 );
70 const canReadMedia =
71 hostEnvironmentId === null ||
72 (fileSession.error === null && fileSession.data !== null && allowsHostMedia(fileSession.data));
73 const assertCanReadMedia = useCallback(() => {
74 if (!canReadHostMedia(hostEnvironmentId)) {
75 throw new Error("This connection cannot read host files.");
76 }
77 }, [hostEnvironmentId]);
78 const fileMenu = useFileContextMenu(hostEnvironmentId);
79 const bridge = typeof window === "undefined" ? undefined : window.desktopBridge;
80 const connection = hostEnvironmentId === null ? null : readPreparedConnection(hostEnvironmentId);
81 const isLocalFile =
82 source.reference?.kind === "file" &&
83 connection !== null &&
84 hostEnvironmentId !== null &&
85 bridge?.supportsLocalTerminal?.({
86 httpBaseUrl: connection.httpBaseUrl,
87 environmentId: hostEnvironmentId,
88 }) === true;
89 const canReveal = isLocalFile && fileMenu.capabilities.revealLabel !== undefined;
90 const canOpenQuickTime =
91 source.kind === "video" &&
92 bridge?.openVideo !== undefined &&
93 (source.asset !== undefined || (source.src !== null && /^https?:/.test(source.src)));
94 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
95 reportFailure: false,
96 refresh: true,
97 });
98 const actionUrl = useCallback(async () => {
99 assertCanReadMedia();
100 if (!source.asset) {
101 if (!source.src) throw new Error("This media is unavailable. Try reopening the preview.");
102 return source.src;
103 }
104 const { environmentId, resource } = source.asset;
105 const connection = readPreparedConnection(environmentId);
106 if (!connection) throw new Error("Reconnect to this environment and try again.");
107 const result = await createAssetUrl({ environmentId, input: { resource } });
108 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
109 assertCanReadMedia();
110 const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl);
111 if (!url) throw new Error("The environment returned an invalid media URL.");
112 return url;
113 }, [source, createAssetUrl, assertCanReadMedia]);
114 const save = useCallback(async () => {
115 await downloadMedia(await actionUrl(), mediaFileName(source));
116 }, [actionUrl, source]);
117 const copyImage = useCallback(async () => {
118 assertCanReadMedia();
119 if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
120 throw new Error(
121 "Image copying is unavailable. Use a secure browser connection or save the image.",
122 );
123 }
124 // Start the clipboard write in the user gesture; fetching/decoding may finish later.
125 await navigator.clipboard.write([
126 new ClipboardItem({ "image/png": actionUrl().then(readMediaPng) }),
127 ]);
128 }, [actionUrl, assertCanReadMedia]);
129 const reveal = useCallback(async () => {
130 assertCanReadMedia();
131 const currentConnection =
132 hostEnvironmentId === null ? null : readPreparedConnection(hostEnvironmentId);
133 if (
134 !canReveal ||
135 source.reference?.kind !== "file" ||
136 hostEnvironmentId === null ||
137 !currentConnection ||
138 bridge?.supportsLocalTerminal?.({
139 httpBaseUrl: currentConnection.httpBaseUrl,
140 environmentId: hostEnvironmentId,
141 }) !== true
142 )
143 return;
144 await fileMenu.activate("reveal-in-folder", {
145 environmentId: hostEnvironmentId,
146 filePath: source.reference.path,
147 workspaceRoot: undefined,
148 });
149 }, [assertCanReadMedia, canReveal, source.reference, hostEnvironmentId, bridge, fileMenu]);
150 const openQuickTime = useCallback(async () => {
151 if (!canOpenQuickTime || !bridge?.openVideo) return;
152 const url = await actionUrl();
153 await bridge.openVideo({ url, name: mediaFileName(source) });
154 }, [canOpenQuickTime, bridge, actionUrl, source]);
155 return {
156 save,
157 copyImage,
158 reveal,
159 openQuickTime,
160 canReveal,
161 revealLabel: fileMenu.capabilities.revealLabel,
162 canOpenQuickTime,
163 canReadMedia,
164 assertCanReadMedia,
165 };
166}
167
168export function useMediaActionMenu(source: MediaActionSource) {
169 const {
170 save,
171 copyImage,
172 reveal,
173 openQuickTime,
174 canReveal,
175 canOpenQuickTime,
176 revealLabel,
177 canReadMedia,
178 assertCanReadMedia,
179 } = useMediaActions(source);
180 const menuOpen = useRef(false);
181 const reference = source.reference;
182
183 const showMenu = async (position: { x: number; y: number }) => {
184 const api = readLocalApi();
185 if (!api || menuOpen.current) return;
186 menuOpen.current = true;
187 let failureTitle = "Could not open media menu";
188 let progressToast: ReturnType<typeof toastManager.add> | undefined;
189 try {
190 const noun = source.kind === "image" ? "image" : "video";
191 const unavailable = !canReadMedia || (source.src === null && source.asset === undefined);
192 const canCopyImage =
193 typeof navigator !== "undefined" &&
194 Boolean(navigator.clipboard?.write) &&
195 typeof ClipboardItem !== "undefined";
196 const items: ContextMenuItem<MediaActionId>[] = [];
197 if (reference?.kind === "file") {
198 items.push({ id: "copy-full-path", label: "Copy full path" });
199 if (reference.relativePath)
200 items.push({ id: "copy-relative-path", label: "Copy relative path" });
201 } else if (reference?.kind === "url") {
202 items.push({ id: "copy-url", label: "Copy URL" });
203 }
204 if (source.onOpenFile)
205 items.push({ id: "open-file", label: "Open in file viewer", disabled: !canReadMedia });
206 if (source.kind === "video" && canReveal && revealLabel !== undefined)
207 items.push({ id: "reveal-in-folder", label: revealLabel, disabled: !canReadMedia });
208 if (source.kind !== "video" || !canReveal)
209 items.push({
210 id: "save",
211 label: source.kind === "video" ? "Download video" : `Save ${noun}`,
212 disabled: unavailable,
213 });
214 if (canOpenQuickTime)
215 items.push({ id: "open-quicktime", label: "Open in QuickTime", disabled: unavailable });
216 if (source.kind === "image") {
217 items.push({
218 id: "copy-image",
219 label: "Copy image",
220 disabled: unavailable || !canCopyImage,
221 });
222 }
223
224 const action = await api.contextMenu.show(items, position);
225 if (!action) return;
226 failureTitle = `Could not ${items.find((item) => item.id === action)?.label.toLowerCase() ?? "complete media action"}`;
227 const text =
228 action === "copy-full-path" && reference?.kind === "file"
229 ? reference.path
230 : action === "copy-relative-path" && reference?.kind === "file"
231 ? reference.relativePath
232 : action === "copy-url" && reference?.kind === "url"
233 ? reference.url
234 : undefined;
235 if (text !== undefined) {
236 await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL");
237 toastManager.add({
238 type: "success",
239 title: action === "copy-url" ? "URL copied" : "Path copied",
240 });
241 } else if (action === "open-file") {
242 assertCanReadMedia();
243 source.onOpenFile?.();
244 } else if (action === "reveal-in-folder") {
245 await reveal();
246 } else if (action === "open-quicktime") {
247 progressToast = toastManager.add({ type: "loading", title: "Opening in QuickTime…" });
248 await openQuickTime();
249 toastManager.close(progressToast);
250 } else if (action === "save" || action === "copy-image") {
251 progressToast = toastManager.add({
252 type: "loading",
253 title: action === "save" ? `Preparing ${noun} download…` : "Copying image…",
254 });
255 await (action === "save" ? save() : copyImage());
256 toastManager.update(progressToast, {
257 type: "success",
258 title: action === "save" ? "Download started" : "Image copied",
259 });
260 }
261 } catch (error) {
262 const toast = stackedThreadToast({
263 type: "error",
264 title: failureTitle,
265 description: error instanceof Error ? error.message : "The media action failed.",
266 });
267 if (progressToast) toastManager.update(progressToast, toast);
268 else toastManager.add(toast);
269 } finally {
270 menuOpen.current = false;
271 }
272 };
273
274 return { showMenu };
275}
276
277/** Adds source-aware actions and a tooltip without a layout wrapper. */
278export function MediaActions({
279 source,
280 children,
281}: {
282 source: MediaActionSource;
283 children: ReactElement;
284}) {
285 const { showMenu } = useMediaActionMenu(source);
286 const [tooltipOpen, setTooltipOpen] = useState(false);
287 const reference = source.reference;
288 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);
289
290 return (
291 <Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
292 <TooltipTrigger
293 render={children}
294 tabIndex={0}
295 onContextMenu={(event) => {
296 if (event.defaultPrevented) return;
297 event.preventDefault();
298 event.stopPropagation();
299 const bounds = event.currentTarget.getBoundingClientRect();
300 setTooltipOpen(false);
301 void showMenu(
302 event.clientX === 0 && event.clientY === 0
303 ? { x: bounds.left, y: bounds.bottom }
304 : { x: event.clientX, y: event.clientY },
305 );
306 }}
307 onKeyDown={(event) => {
308 if (
309 event.defaultPrevented ||
310 !(event.key === "ContextMenu" || (event.shiftKey && event.key === "F10"))
311 )
312 return;
313 event.preventDefault();
314 event.stopPropagation();
315 const bounds = event.currentTarget.getBoundingClientRect();
316 setTooltipOpen(false);
317 void showMenu({ x: bounds.left, y: bounds.bottom });
318 }}
319 />
320 <TooltipPopup variant="code">{tooltip}</TooltipPopup>
321 </Tooltip>
322 );
323}