| 1 | import type { MediaActionId } from "@t3tools/client-runtime/media-actions"; |
| 2 | import { |
| 3 | mediaReferenceFileName, |
| 4 | type MediaReference, |
| 5 | } from "@t3tools/client-runtime/media-reference"; |
| 6 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 7 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import { |
| 9 | AuthFilesystemReadScope, |
| 10 | type AssetResource, |
| 11 | type ContextMenuItem, |
| 12 | type EnvironmentId, |
| 13 | sessionGrantsScope, |
| 14 | type SessionGrantInput, |
| 15 | } from "@t3tools/contracts"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import { AsyncResult } from "effect/reactivity"; |
| 18 | import { useCallback, useRef, useState, type ReactElement } from "react"; |
| 19 | |
| 20 | import { useFileContextMenu } from "../../fileContextMenu"; |
| 21 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; |
| 22 | import { readLocalApi } from "../../localApi"; |
| 23 | import { appAtomRegistry } from "../../rpc/atomRegistry"; |
| 24 | import { assetEnvironment } from "../../state/assets"; |
| 25 | import { useEnvironmentQuery } from "../../state/query"; |
| 26 | import { environmentSession, readPreparedConnection } from "../../state/session"; |
| 27 | import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; |
| 28 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 30 | import { downloadMedia, readMediaPng } from "./mediaContent"; |
| 31 | |
| 32 | export 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 | |
| 41 | function 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. */ |
| 48 | function allowsHostMedia(session: SessionGrantInput | null) { |
| 49 | return session === null || sessionGrantsScope(session, AuthFilesystemReadScope); |
| 50 | } |
| 51 | |
| 52 | function 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. */ |
| 59 | export 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 | |
| 168 | export 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. */ |
| 278 | export 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 | } |