diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9ea4503..d342c2e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1594,6 +1594,7 @@ function findRootIdForPath(filePath: string): string | null { } async function handlePlay(filePath: string) { + const actionStart = performance.now() const rootId = findRootIdForPath(filePath) if (!rootId) { console.error("Cannot play: unknown root for path", filePath) @@ -1603,7 +1604,10 @@ async function handlePlay(filePath: string) { mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS } try { - await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd) + await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd, { + actionStartedAt: actionStart, + source: "App.handlePlay", + }) if (isMpcFamilySelected()) { const connected = await tryConnectMpcBe() if (connected) { @@ -1617,13 +1621,17 @@ async function handlePlay(filePath: string) { } async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) { + const actionStart = performance.now() const rootId = explicitRootId || findRootIdForPath(folderPath) if (!rootId) { console.error("Cannot open folder: unknown root for path", folderPath) return } try { - await openFolder(rootId, folderPath) + await openFolder(rootId, folderPath, { + actionStartedAt: actionStart, + source: "App.handleOpenFolder", + }) } catch (e) { console.error("Failed to open folder:", e) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 52c7d0a..95cd99a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -14,6 +14,40 @@ export interface RootEntry { path: string } +interface ActionTimingContext { + actionStartedAt?: number + source?: string +} + +function nowMs(): number { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return performance.now() + } + return Date.now() +} + +function makeTraceId(action: string): string { + const suffix = Math.random().toString(16).slice(2, 8) + return `${action}-${Date.now().toString(36)}-${suffix}` +} + +function logActionTiming( + action: string, + traceId: string, + status: number, + actionToFetchMs: number, + fetchMs: number, + totalMs: number, + serverTiming: string | null, + source?: string, +) { + const sourceTag = source ? ` source=${source}` : "" + const serverTag = serverTiming ? ` serverTiming=${serverTiming}` : "" + console.info( + `[timing:${action}] trace=${traceId}${sourceTag} status=${status} actionToFetch=${actionToFetchMs.toFixed(1)}ms fetch=${fetchMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms${serverTag}`, + ) +} + export function normalizeMediaPath(input: string): string { return input .replace(/\\/g, "/") @@ -178,23 +212,50 @@ export async function playMedia( filePath: string, playerId?: string | null, playerCustomCmd?: string | null, + timing?: ActionTimingContext, ): Promise { const normalizedPath = normalizeMediaPath(filePath) const body: Record = { file_path: normalizedPath } if (playerId) body.player_id = playerId if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd + const actionStart = timing?.actionStartedAt ?? nowMs() + const traceId = makeTraceId("play") try { + const fetchStart = nowMs() + const actionToFetchMs = Math.max(0, fetchStart - actionStart) + const clientSentMs = Date.now() + const actionStartEpochMs = clientSentMs - actionToFetchMs const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-MediaHive-Trace-Id": traceId, + "X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3), + "X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3), + }, body: JSON.stringify(body), }) + const fetchMs = Math.max(0, nowMs() - fetchStart) + const totalMs = Math.max(0, nowMs() - actionStart) + const serverTiming = response.headers.get("server-timing") + const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId + logActionTiming( + "play", + responseTraceId, + response.status, + actionToFetchMs, + fetchMs, + totalMs, + serverTiming, + timing?.source, + ) if (!response.ok) { const error = await response.json() throw new Error(error.detail || response.statusText) } } catch (e) { - console.error("Play media error:", e) + const totalMs = Math.max(0, nowMs() - actionStart) + console.error(`Play media error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e) alert(`Failed to play media.\n\n${e}`) } } @@ -202,20 +263,50 @@ export async function playMedia( /** * Open a folder in the system file manager */ -export async function openFolder(rootId: string, folderPath: string): Promise { +export async function openFolder( + rootId: string, + folderPath: string, + timing?: ActionTimingContext, +): Promise { const normalizedPath = normalizeMediaPath(folderPath) + const actionStart = timing?.actionStartedAt ?? nowMs() + const traceId = makeTraceId("open-folder") try { + const fetchStart = nowMs() + const actionToFetchMs = Math.max(0, fetchStart - actionStart) + const clientSentMs = Date.now() + const actionStartEpochMs = clientSentMs - actionToFetchMs const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-MediaHive-Trace-Id": traceId, + "X-MediaHive-Client-Sent-Ms": clientSentMs.toFixed(3), + "X-MediaHive-Client-Action-Start-Ms": actionStartEpochMs.toFixed(3), + }, body: JSON.stringify({ folder_path: normalizedPath }), }) + const fetchMs = Math.max(0, nowMs() - fetchStart) + const totalMs = Math.max(0, nowMs() - actionStart) + const serverTiming = response.headers.get("server-timing") + const responseTraceId = response.headers.get("x-mediahive-trace-id") || traceId + logActionTiming( + "open-folder", + responseTraceId, + response.status, + actionToFetchMs, + fetchMs, + totalMs, + serverTiming, + timing?.source, + ) if (!response.ok) { const error = await response.json() throw new Error(error.detail || response.statusText) } } catch (e) { - console.error("Open folder error:", e) + const totalMs = Math.max(0, nowMs() - actionStart) + console.error(`Open folder error after ${totalMs.toFixed(1)}ms (trace=${traceId}):`, e) alert(`Failed to open folder.\n\n${e}`) } } diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index a60ae39..9d1a634 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -225,6 +225,7 @@ :play-label="getPlayLabel(versionActionMenu.filePath)" @play="handlePlayVersion(versionActionMenu.filePath)" @open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)" + @close="closeVersionActionMenu" /> @@ -250,6 +251,7 @@ import { navAttrs, registerOutOfBoundsNavigationHandler, FOCUSABLE_ATTR, + setModalOpen, } from "../composables/useKeyboardNavigation" const props = defineProps<{ @@ -821,10 +823,14 @@ const versionActionMenu = ref<{ }) function closeVersionActionMenu() { + const wasVisible = versionActionMenu.value.visible versionActionMenu.value.visible = false versionActionMenu.value.filePath = null versionActionMenu.value.rootName = null versionActionMenu.value.rootId = null + if (wasVisible) { + setModalOpen(false) + } } function getPlayLabel(filePath: string | null): string { @@ -842,6 +848,7 @@ function handlePlayVersion(filePath: string | null) { function handleVersionContextMenu(event: MouseEvent, version: Torrent) { event.preventDefault() event.stopPropagation() + setModalOpen(true) const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id) versionActionMenu.value = { visible: true, @@ -936,16 +943,52 @@ function handleResize() { viewportWidth.value = window.innerWidth } +function handleGamepadAction(event: Event) { + const actionEvent = event as CustomEvent<{ action?: string }> + if (actionEvent.detail?.action !== "menu") return + + const active = document.activeElement as HTMLElement | null + if (!active || !active.hasAttribute("data-nav-release-item")) return + + const row = parseInt(active.getAttribute("data-nav-row") || "-1", 10) + if (row < 0) return + + const index = row - 2 // releases start at nav row 2 + const version = movieVersions.value[index] + if (!version) return + + actionEvent.preventDefault() + setModalOpen(true) + const rect = active.getBoundingClientRect() + const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id) + versionActionMenu.value = { + visible: true, + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + filePath: version.playable_file || null, + rootName: props.getRootName(rootId) || null, + rootId, + } + nextTick(() => { + const firstAction = document.querySelector( + ".version-action-menu .version-action-item:not(:disabled)", + ) as HTMLElement | null + firstAction?.focus() + }) +} + onMounted(() => { document.addEventListener("keydown", handleMovieMenuKeydown, true) window.addEventListener("resize", handleResize) window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true }) + window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) }) onUnmounted(() => { document.removeEventListener("keydown", handleMovieMenuKeydown, true) window.removeEventListener("resize", handleResize) window.removeEventListener("mousemove", handleHoverAudioMouseMove) + window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) clearHoverAudioIdleTimer() disposeOutOfBoundsHandler?.() disposeOutOfBoundsHandler = null diff --git a/frontend/src/components/ReleaseActionMenu.vue b/frontend/src/components/ReleaseActionMenu.vue index a5fa3bd..90c3dcf 100644 --- a/frontend/src/components/ReleaseActionMenu.vue +++ b/frontend/src/components/ReleaseActionMenu.vue @@ -1,5 +1,12 @@