Clean up series nested context menus and implement keyboard/gamepad navigation for these functions.

This commit is contained in:
2026-06-02 22:17:47 +00:00
parent f79c044250
commit b09118c8cb
8 changed files with 315 additions and 274 deletions
+10 -2
View File
@@ -1594,6 +1594,7 @@ function findRootIdForPath(filePath: string): string | null {
} }
async function handlePlay(filePath: string) { async function handlePlay(filePath: string) {
const actionStart = performance.now()
const rootId = findRootIdForPath(filePath) const rootId = findRootIdForPath(filePath)
if (!rootId) { if (!rootId) {
console.error("Cannot play: unknown root for path", filePath) 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 mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS
} }
try { try {
await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd) await playMedia(rootId, filePath, settings.playerId, settings.playerCustomCmd, {
actionStartedAt: actionStart,
source: "App.handlePlay",
})
if (isMpcFamilySelected()) { if (isMpcFamilySelected()) {
const connected = await tryConnectMpcBe() const connected = await tryConnectMpcBe()
if (connected) { if (connected) {
@@ -1617,13 +1621,17 @@ async function handlePlay(filePath: string) {
} }
async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) { async function handleOpenFolder(folderPath: string, explicitRootId?: string | null) {
const actionStart = performance.now()
const rootId = explicitRootId || findRootIdForPath(folderPath) const rootId = explicitRootId || findRootIdForPath(folderPath)
if (!rootId) { if (!rootId) {
console.error("Cannot open folder: unknown root for path", folderPath) console.error("Cannot open folder: unknown root for path", folderPath)
return return
} }
try { try {
await openFolder(rootId, folderPath) await openFolder(rootId, folderPath, {
actionStartedAt: actionStart,
source: "App.handleOpenFolder",
})
} catch (e) { } catch (e) {
console.error("Failed to open folder:", e) console.error("Failed to open folder:", e)
} }
+96 -5
View File
@@ -14,6 +14,40 @@ export interface RootEntry {
path: string 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 { export function normalizeMediaPath(input: string): string {
return input return input
.replace(/\\/g, "/") .replace(/\\/g, "/")
@@ -178,23 +212,50 @@ export async function playMedia(
filePath: string, filePath: string,
playerId?: string | null, playerId?: string | null,
playerCustomCmd?: string | null, playerCustomCmd?: string | null,
timing?: ActionTimingContext,
): Promise<void> { ): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath) const normalizedPath = normalizeMediaPath(filePath)
const body: Record<string, unknown> = { file_path: normalizedPath } const body: Record<string, unknown> = { file_path: normalizedPath }
if (playerId) body.player_id = playerId if (playerId) body.player_id = playerId
if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd if (playerCustomCmd) body.player_custom_cmd = playerCustomCmd
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("play")
try { 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)}`, { const response = await fetch(`/api/play/${encodeURIComponent(rootId)}`, {
method: "POST", 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), 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) { if (!response.ok) {
const error = await response.json() const error = await response.json()
throw new Error(error.detail || response.statusText) throw new Error(error.detail || response.statusText)
} }
} catch (e) { } 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}`) alert(`Failed to play media.\n\n${e}`)
} }
} }
@@ -202,20 +263,50 @@ export async function playMedia(
/** /**
* Open a folder in the system file manager * Open a folder in the system file manager
*/ */
export async function openFolder(rootId: string, folderPath: string): Promise<void> { export async function openFolder(
rootId: string,
folderPath: string,
timing?: ActionTimingContext,
): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath) const normalizedPath = normalizeMediaPath(folderPath)
const actionStart = timing?.actionStartedAt ?? nowMs()
const traceId = makeTraceId("open-folder")
try { 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)}`, { const response = await fetch(`/api/open-folder/${encodeURIComponent(rootId)}`, {
method: "POST", 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 }), 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) { if (!response.ok) {
const error = await response.json() const error = await response.json()
throw new Error(error.detail || response.statusText) throw new Error(error.detail || response.statusText)
} }
} catch (e) { } 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}`) alert(`Failed to open folder.\n\n${e}`)
} }
} }
+43
View File
@@ -225,6 +225,7 @@
:play-label="getPlayLabel(versionActionMenu.filePath)" :play-label="getPlayLabel(versionActionMenu.filePath)"
@play="handlePlayVersion(versionActionMenu.filePath)" @play="handlePlayVersion(versionActionMenu.filePath)"
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)" @open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
@close="closeVersionActionMenu"
/> />
</Teleport> </Teleport>
</div> </div>
@@ -250,6 +251,7 @@ import {
navAttrs, navAttrs,
registerOutOfBoundsNavigationHandler, registerOutOfBoundsNavigationHandler,
FOCUSABLE_ATTR, FOCUSABLE_ATTR,
setModalOpen,
} from "../composables/useKeyboardNavigation" } from "../composables/useKeyboardNavigation"
const props = defineProps<{ const props = defineProps<{
@@ -821,10 +823,14 @@ const versionActionMenu = ref<{
}) })
function closeVersionActionMenu() { function closeVersionActionMenu() {
const wasVisible = versionActionMenu.value.visible
versionActionMenu.value.visible = false versionActionMenu.value.visible = false
versionActionMenu.value.filePath = null versionActionMenu.value.filePath = null
versionActionMenu.value.rootName = null versionActionMenu.value.rootName = null
versionActionMenu.value.rootId = null versionActionMenu.value.rootId = null
if (wasVisible) {
setModalOpen(false)
}
} }
function getPlayLabel(filePath: string | null): string { function getPlayLabel(filePath: string | null): string {
@@ -842,6 +848,7 @@ function handlePlayVersion(filePath: string | null) {
function handleVersionContextMenu(event: MouseEvent, version: Torrent) { function handleVersionContextMenu(event: MouseEvent, version: Torrent) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
setModalOpen(true)
const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id) const rootId = version.root_id || ((props.item.data as MovieUi).root_id ?? props.item.root_id)
versionActionMenu.value = { versionActionMenu.value = {
visible: true, visible: true,
@@ -936,16 +943,52 @@ function handleResize() {
viewportWidth.value = window.innerWidth 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(() => { onMounted(() => {
document.addEventListener("keydown", handleMovieMenuKeydown, true) document.addEventListener("keydown", handleMovieMenuKeydown, true)
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true }) window.addEventListener("mousemove", handleHoverAudioMouseMove, { passive: true })
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", handleMovieMenuKeydown, true) document.removeEventListener("keydown", handleMovieMenuKeydown, true)
window.removeEventListener("resize", handleResize) window.removeEventListener("resize", handleResize)
window.removeEventListener("mousemove", handleHoverAudioMouseMove) window.removeEventListener("mousemove", handleHoverAudioMouseMove)
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
clearHoverAudioIdleTimer() clearHoverAudioIdleTimer()
disposeOutOfBoundsHandler?.() disposeOutOfBoundsHandler?.()
disposeOutOfBoundsHandler = null disposeOutOfBoundsHandler = null
+47 -1
View File
@@ -1,5 +1,12 @@
<template> <template>
<div v-if="visible" ref="menuRef" class="version-action-menu" :style="menuStyle"> <div
v-if="visible"
ref="menuRef"
class="version-action-menu"
:style="menuStyle"
tabindex="-1"
@keydown="handleKeydown"
>
<div class="version-action-path" :title="resolvedPath"> <div class="version-action-path" :title="resolvedPath">
{{ resolvedPath }} {{ resolvedPath }}
</div> </div>
@@ -47,6 +54,7 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
play: [] play: []
openFolder: [] openFolder: []
close: []
}>() }>()
const menuRef = ref<HTMLElement | null>(null) const menuRef = ref<HTMLElement | null>(null)
@@ -73,6 +81,44 @@ const menuStyle = computed(() => ({
const disabled = computed(() => !props.filePath) const disabled = computed(() => !props.filePath)
function getFocusableElements(): HTMLElement[] {
if (!menuRef.value) return []
return Array.from(
menuRef.value.querySelectorAll<HTMLElement>(".version-action-item:not(:disabled)")
)
}
function focusNext(delta: number) {
const elements = getFocusableElements()
if (elements.length === 0) return
const currentIndex = elements.findIndex((el) => el === document.activeElement)
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + elements.length) % elements.length
elements[nextIndex].focus()
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Tab") {
event.preventDefault()
focusNext(event.shiftKey ? -1 : 1)
return
}
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
focusNext(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
focusNext(-1)
return
}
if (event.key === "Escape") {
event.preventDefault()
emit("close")
}
}
function clampToViewport() { function clampToViewport() {
const menu = menuRef.value const menu = menuRef.value
if (!menu) return if (!menu) return
+31 -14
View File
@@ -3,12 +3,13 @@
class="version-row" class="version-row"
:class="{ :class="{
'version-best': best, 'version-best': best,
'version-selectable': isSelectable, 'version-selectable': isSelectable && !inertCard,
'version-disabled': isDisabled, 'version-disabled': isDisabled,
'version-menu': variant === 'menu', 'version-menu': variant === 'menu',
'version-with-actions': showActions, 'version-with-actions': showActions,
'version-inert': inertCard,
}" }"
tabindex="0" :tabindex="inertCard ? undefined : 0"
:title="resolvedTitle" :title="resolvedTitle"
v-bind="$attrs" v-bind="$attrs"
@click="handleActivate" @click="handleActivate"
@@ -76,14 +77,16 @@
tabindex="0" tabindex="0"
@click.stop="emit('play')" @click.stop="emit('play')"
:disabled="!torrent.playable_file" :disabled="!torrent.playable_file"
:title="playLabel"
> >
{{ playLabel }}
</button> </button>
<button <button
class="ctx-btn ctx-btn-folder" class="ctx-btn ctx-btn-folder"
tabindex="0" tabindex="0"
@click.stop="emit('openFolder')" @click.stop="emit('openFolder')"
:disabled="!torrent.playable_file" :disabled="!torrent.playable_file"
title="Open Folder"
> >
📁 📁
</button> </button>
@@ -123,6 +126,9 @@ const props = withDefaults(
playLabel?: string playLabel?: string
title?: string title?: string
variant?: "default" | "menu" variant?: "default" | "menu"
/** When true, the card itself is not interactive (no tabindex, no click/keyboard handlers).
* Use with showActions to make only the inline buttons interactive. */
inertCard?: boolean
}>(), }>(),
{ {
best: false, best: false,
@@ -133,6 +139,7 @@ const props = withDefaults(
playLabel: "Play", playLabel: "Play",
title: undefined, title: undefined,
variant: "default", variant: "default",
inertCard: false,
}, },
) )
@@ -354,7 +361,7 @@ const resolvedTitle = computed(() => {
}) })
function handleActivate(event: MouseEvent | KeyboardEvent) { function handleActivate(event: MouseEvent | KeyboardEvent) {
if (!isSelectable.value || isDisabled.value) return if (props.inertCard || !isSelectable.value || isDisabled.value) return
emit("activate", event) emit("activate", event)
} }
</script> </script>
@@ -409,6 +416,15 @@ html.mouse-active .version-row.version-best:hover {
outline-offset: 2px; outline-offset: 2px;
} }
.version-row.version-inert {
cursor: default;
}
.version-row.version-inert .version-main,
.version-row.version-inert .version-dolby-cell {
pointer-events: none;
}
.version-row.version-disabled { .version-row.version-disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.75; opacity: 0.75;
@@ -607,31 +623,32 @@ html.mouse-active .version-row.version-best:hover {
} }
.ctx-btn { .ctx-btn {
border: 1px solid rgba(255, 255, 255, 0.18); border: none;
background: rgba(255, 255, 255, 0.08); background: transparent;
color: #fff; color: rgba(255, 255, 255, 0.65);
border-radius: 6px; border-radius: 6px;
padding: 6px 10px; padding: 4px 8px;
font-size: 0.78rem; font-size: 2em;
line-height: 1;
cursor: pointer; cursor: pointer;
transition: color 0.15s ease;
} }
html.mouse-active .ctx-btn:hover:not(:disabled), html.mouse-active .ctx-btn:hover:not(:disabled),
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled), html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
.ctx-btn:focus-visible:not(:disabled) { .ctx-btn:focus-visible:not(:disabled) {
background: rgba(255, 255, 255, 0.16); color: #fff;
border-color: rgba(255, 255, 255, 0.35);
outline: none; outline: none;
} }
.ctx-btn:disabled { .ctx-btn:disabled {
opacity: 0.5; opacity: 0.35;
cursor: not-allowed; cursor: not-allowed;
} }
.ctx-btn-folder { .ctx-btn-folder {
width: 34px; width: auto;
text-align: center; text-align: center;
padding: 6px 0; padding: 4px 8px;
} }
</style> </style>
+65 -249
View File
@@ -167,51 +167,24 @@
</div> </div>
</section> </section>
<!-- Context menu --> <!-- Episode release menu -->
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="contextMenu.visible" v-if="episodeReleaseMenu.visible"
class="context-menu-backdrop" class="episode-release-menu-backdrop"
@click="closeContextMenu" @click="closeEpisodeReleaseMenu"
@contextmenu.prevent="closeContextMenu" @contextmenu.prevent="closeEpisodeReleaseMenu"
></div> ></div>
<div <EpisodeReleaseMenu
v-if="contextMenu.visible && contextMenu.episode" :visible="episodeReleaseMenu.visible"
class="context-menu" :x="episodeReleaseMenu.x"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }" :y="episodeReleaseMenu.y"
> :episode-name="episodeReleaseMenu.episode?.name || `Episode ${episodeReleaseMenu.episode?.episode_number}`"
<div class="context-menu-header"> :releases="episodeReleaseMenuReleases"
{{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }} :has-resume-position="props.hasResumePosition"
</div> @play="handlePlayVersion"
<div v-if="Object.values(contextMenu.episode.files || {}).length > 0"> @open-folder="handleOpenFolderFromMenu"
<ReleaseVersionCard @close="closeEpisodeReleaseMenu"
v-for="(torrent, index) in sortTorrentsByPreference(Object.values(contextMenu.episode.files || {}))"
:key="index"
class="context-menu-version"
:torrent="torrent"
variant="menu"
compact-flags
:title="
torrent.playable_file
? 'Click to play/continue. Alt+Click, Alt+Enter, or Cmd/Ctrl+E to open folder. Right-click for actions.'
: 'No playable file'
"
@activate="handleVersionActivate(torrent, $event)"
@keydown="handleVersionShortcutKeydown($event, torrent)"
@contextmenu="handleVersionContextMenu($event, torrent)"
/>
</div>
<div v-else class="context-menu-empty">No versions available</div>
</div>
<ReleaseActionMenu
:visible="versionActionMenu.visible"
:x="versionActionMenu.x"
:y="versionActionMenu.y"
:file-path="versionActionMenu.filePath"
:root-name="versionActionMenu.rootName"
:play-label="getPlayLabel(versionActionMenu.filePath)"
@play="handlePlayVersion(versionActionMenu.filePath)"
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
/> />
</Teleport> </Teleport>
</div> </div>
@@ -219,11 +192,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue" import { computed, ref, nextTick, watch, onMounted, onUnmounted } from "vue"
import type { Series, Season, Episode, Torrent, MovieUi } from "../types" import type { Series, Season, Episode, MovieUi } from "../types"
import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api" import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrowser } from "../api"
import { navAttrs } from "../composables/useKeyboardNavigation" import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import ReleaseVersionCard from "./ReleaseVersionCard.vue" import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings" import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{ const props = defineProps<{
@@ -486,8 +458,8 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Context menu state // Episode release menu state (single-layer menu for all releases)
const contextMenu = ref<{ const episodeReleaseMenu = ref<{
visible: boolean visible: boolean
x: number x: number
y: number y: number
@@ -499,24 +471,13 @@ const contextMenu = ref<{
episode: null, episode: null,
}) })
const versionActionMenu = ref<{
visible: boolean
x: number
y: number
filePath: string | null
rootName: string | null
rootId: string | null
}>({
visible: false,
x: 0,
y: 0,
filePath: null,
rootName: null,
rootId: null,
})
const releaseMenuOriginElement = ref<HTMLElement | null>(null) const releaseMenuOriginElement = ref<HTMLElement | null>(null)
const episodeReleaseMenuReleases = computed(() => {
if (!episodeReleaseMenu.value.episode) return []
return sortTorrentsByPreference(Object.values(episodeReleaseMenu.value.episode.files || {}))
})
function normalizeMatchText(value: string | null | undefined): string { function normalizeMatchText(value: string | null | undefined): string {
return (value || "") return (value || "")
.toLowerCase() .toLowerCase()
@@ -578,7 +539,7 @@ const matchingSeriesMovies = computed(() => {
}) })
}) })
// Show context menu on right-click // Show episode release menu on right-click (single-layer menu)
function handleContextMenu(event: MouseEvent, episode: Episode) { function handleContextMenu(event: MouseEvent, episode: Episode) {
event.preventDefault() event.preventDefault()
releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null releaseMenuOriginElement.value = event.currentTarget as HTMLElement | null
@@ -586,23 +547,16 @@ function handleContextMenu(event: MouseEvent, episode: Episode) {
} }
function openEpisodeReleaseMenu(episode: Episode, x: number, y: number) { function openEpisodeReleaseMenu(episode: Episode, x: number, y: number) {
closeVersionActionMenu() setModalOpen(true)
contextMenu.value = { episodeReleaseMenu.value = {
visible: true, visible: true,
x, x,
y, y,
episode, episode,
} }
// Add Escape key listener (capturing phase to intercept before other handlers) // Add Escape key listener as safety net (capture phase)
nextTick(() => { nextTick(() => {
document.addEventListener("keydown", handleContextMenuKeydown, true) document.addEventListener("keydown", handleEpisodeMenuEscape, true)
// Focus first selectable version card.
const firstCard = document.querySelector(
".context-menu .version-row.version-selectable",
) as HTMLElement
if (firstCard) {
firstCard.focus()
}
}) })
} }
@@ -616,102 +570,48 @@ function openEpisodeReleaseMenuFromElement(episode: Episode, element: HTMLElemen
openEpisodeReleaseMenu(episode, rect.left + rect.width / 2, rect.top + rect.height / 2) openEpisodeReleaseMenu(episode, rect.left + rect.width / 2, rect.top + rect.height / 2)
} }
// Handle Escape and arrow keys in context menu (capturing phase to intercept before global handler) // Capture-phase Escape handler as safety net for episode release menu
function handleContextMenuKeydown(event: KeyboardEvent) { function handleEpisodeMenuEscape(event: KeyboardEvent) {
if (!contextMenu.value.visible) return if (!episodeReleaseMenu.value.visible) return
const popupFocusable = getPopupFocusableElements()
if (event.key === "Tab") {
if (popupFocusable.length === 0) return
event.preventDefault()
event.stopPropagation()
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
const delta = event.shiftKey ? -1 : 1
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
popupFocusable[nextIndex].focus()
return
}
if (
event.key === "ArrowDown" ||
event.key === "ArrowRight" ||
event.key === "ArrowUp" ||
event.key === "ArrowLeft"
) {
if (popupFocusable.length === 0) return
event.preventDefault()
event.stopPropagation()
const currentIndex = popupFocusable.findIndex((el) => el === document.activeElement)
const delta = event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1
const nextIndex =
currentIndex < 0 ? 0 : (currentIndex + delta + popupFocusable.length) % popupFocusable.length
popupFocusable[nextIndex].focus()
return
}
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (versionActionMenu.value.visible) { closeEpisodeReleaseMenu()
closeVersionActionMenu()
return
}
closeContextMenu()
} }
} }
function getPopupFocusableElements(): HTMLElement[] { // Close episode release menu
const releaseItems = Array.from( function closeEpisodeReleaseMenu() {
document.querySelectorAll<HTMLElement>(".context-menu .version-row.version-selectable"), episodeReleaseMenu.value.visible = false
) episodeReleaseMenu.value.episode = null
const actionItems = versionActionMenu.value.visible document.removeEventListener("keydown", handleEpisodeMenuEscape, true)
? Array.from( setModalOpen(false)
document.querySelectorAll<HTMLElement>(
".version-action-menu .version-action-item:not(:disabled)",
),
)
: []
return [...releaseItems, ...actionItems]
}
// Close context menu
function closeContextMenu() {
closeVersionActionMenu()
contextMenu.value.visible = false
document.removeEventListener("keydown", handleContextMenuKeydown, true)
nextTick(() => { nextTick(() => {
releaseMenuOriginElement.value?.focus() releaseMenuOriginElement.value?.focus()
}) })
} }
function closeVersionActionMenu() {
versionActionMenu.value.visible = false
versionActionMenu.value.filePath = null
versionActionMenu.value.rootName = null
versionActionMenu.value.rootId = null
}
function handleGamepadAction(event: Event) { function handleGamepadAction(event: Event) {
const actionEvent = event as CustomEvent<{ action?: string }> const actionEvent = event as CustomEvent<{ action?: string }>
if (actionEvent.detail?.action !== "menu") return if (actionEvent.detail?.action !== "menu") return
const active = document.activeElement as HTMLElement | null const active = document.activeElement as HTMLElement | null
if (!active || !active.classList.contains("episode-tile")) return if (!active) return
const seasonIndex = parseInt(active.getAttribute("data-season-index") || "-1", 10) // Episode tile on series page
const episodeIndex = parseInt(active.getAttribute("data-episode-index") || "-1", 10) if (active.classList.contains("episode-tile")) {
if (seasonIndex < 0 || episodeIndex < 0) return const seasonIndex = parseInt(active.getAttribute("data-season-index") || "-1", 10)
const episodeIndex = parseInt(active.getAttribute("data-episode-index") || "-1", 10)
if (seasonIndex < 0 || episodeIndex < 0) return
const season = props.series.seasons?.[seasonIndex] const season = props.series.seasons?.[seasonIndex]
const episode = season?.episodes?.[episodeIndex] const episode = season?.episodes?.[episodeIndex]
if (!episode) return if (!episode) return
actionEvent.preventDefault() actionEvent.preventDefault()
openEpisodeReleaseMenuFromElement(episode, active) openEpisodeReleaseMenuFromElement(episode, active)
return
}
} }
// Play specific version // Play specific version
@@ -719,61 +619,18 @@ function handlePlayVersion(filePath: string | null) {
if (filePath) { if (filePath) {
emit("play", filePath) emit("play", filePath)
} }
closeVersionActionMenu() closeEpisodeReleaseMenu()
closeContextMenu()
} }
function getPlayLabel(filePath: string | null): string { // Open folder for a version from the episode release menu
return props.hasResumePosition(filePath) ? "Continue" : "Play" function handleOpenFolderFromMenu(filePath: string) {
} if (!filePath) return
const torrent = episodeReleaseMenu.value.episode?.files
// Open folder for a version ? Object.values(episodeReleaseMenu.value.episode.files).find((t) => t.playable_file === filePath)
function handleOpenFolder(folderPath: string, rootId?: string | null) { : undefined
if (!folderPath) return const rootId = torrent?.root_id ?? props.series.root_id ?? null
emit("openFolder", folderPath, rootId) emit("openFolder", filePath, rootId)
closeVersionActionMenu() closeEpisodeReleaseMenu()
closeContextMenu()
}
function handleVersionActivate(torrent: Torrent, event: MouseEvent | KeyboardEvent) {
if (!torrent.playable_file) return
const rootId = torrent.root_id ?? props.series.root_id ?? null
if (event.altKey) {
handleOpenFolder(torrent.playable_file, rootId)
return
}
handlePlayVersion(torrent.playable_file)
}
function handleVersionShortcutKeydown(event: KeyboardEvent, torrent: Torrent) {
if (!torrent.playable_file) return
const rootId = torrent.root_id ?? props.series.root_id ?? null
const key = event.key.toLowerCase()
if (key === "e" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
event.stopPropagation()
handleOpenFolder(torrent.playable_file, rootId)
}
}
function handleVersionContextMenu(event: MouseEvent, torrent: Torrent) {
event.preventDefault()
event.stopPropagation()
const rootId = torrent.root_id ?? props.series.root_id ?? null
versionActionMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
filePath: torrent.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()
})
} }
// Video refs for hover effects // Video refs for hover effects
@@ -1712,51 +1569,10 @@ html.mouse-active .episode-tile:hover .tile-play {
} }
} }
/* Context menu styles */ /* Episode release menu backdrop */
.context-menu-backdrop { .episode-release-menu-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 999; z-index: 999;
} }
.context-menu {
position: fixed;
z-index: 1000;
background: rgba(20, 20, 30, 0.98);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
min-width: 280px;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
overflow: visible;
padding: 8px;
}
.context-menu-header {
padding: 10px 12px;
font-weight: 600;
font-size: 0.9rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.context-menu-version {
margin-bottom: 6px;
}
.context-menu-version:last-child {
margin-bottom: 0;
}
.context-menu-empty {
padding: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.85rem;
}
</style> </style>
@@ -28,6 +28,8 @@ const activeNavigationScope = ref<string | null>(null)
const desiredCol = ref<number | null>(null) const desiredCol = ref<number | null>(null)
// Track if global handlers are installed // Track if global handlers are installed
let handlersInstalled = false let handlersInstalled = false
// Track open modal count — when > 0, global keyboard navigation is suspended
let modalOpenCount = 0
// Data attribute names // Data attribute names
const FOCUSABLE_ATTR = "data-nav-focusable" const FOCUSABLE_ATTR = "data-nav-focusable"
@@ -760,6 +762,8 @@ function shouldAllowNavigationFromInput(target: HTMLElement, direction: string):
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (modalOpenCount > 0) return
const target = event.target as HTMLElement const target = event.target as HTMLElement
const direction = { const direction = {
@@ -809,6 +813,8 @@ function handleKeyDown(event: KeyboardEvent) {
} }
function handleEnterKey(event: KeyboardEvent) { function handleEnterKey(event: KeyboardEvent) {
if (modalOpenCount > 0) return
if (event.key !== "Enter") return if (event.key !== "Enter") return
if (event.defaultPrevented) return if (event.defaultPrevented) return
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
@@ -835,6 +841,14 @@ export function setActiveNavigationScope(scope: string | null) {
} }
} }
/**
* Suspend global keyboard navigation while a modal/popup is open.
* Call with `true` when opening, `false` when closing. Supports nesting.
*/
export function setModalOpen(open: boolean) {
modalOpenCount = Math.max(0, modalOpenCount + (open ? 1 : -1))
}
export function installKeyboardNavigation() { export function installKeyboardNavigation() {
if (handlersInstalled) return if (handlersInstalled) return
handlersInstalled = true handlersInstalled = true
+9 -3
View File
@@ -300,7 +300,9 @@ class PlaybackStateCache:
class EventLoopLagMonitor: class EventLoopLagMonitor:
"""Tracks event-loop scheduling lag over a sliding window.""" """Tracks event-loop scheduling lag over a sliding window."""
def __init__(self, sample_interval: float = 0.05, window_seconds: float = 10.0) -> None: def __init__(
self, sample_interval: float = 0.05, window_seconds: float = 10.0
) -> None:
self._sample_interval = sample_interval self._sample_interval = sample_interval
self._window_seconds = window_seconds self._window_seconds = window_seconds
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
@@ -955,7 +957,9 @@ async def play_media(root_id: str, request: Request, response: Response):
client_to_server_ms: float | None = None client_to_server_ms: float | None = None
if client_sent_ms_hdr: if client_sent_ms_hdr:
with suppress(ValueError): with suppress(ValueError):
client_to_server_ms = max(0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)) client_to_server_ms = max(
0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)
)
ctx = _get_context(root_id) ctx = _get_context(root_id)
body_t0 = time.perf_counter() body_t0 = time.perf_counter()
@@ -1033,7 +1037,9 @@ async def open_folder(root_id: str, request: Request, response: Response):
client_to_server_ms: float | None = None client_to_server_ms: float | None = None
if client_sent_ms_hdr: if client_sent_ms_hdr:
with suppress(ValueError): with suppress(ValueError):
client_to_server_ms = max(0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)) client_to_server_ms = max(
0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr)
)
ctx = _get_context(root_id) ctx = _get_context(root_id)
body_t0 = time.perf_counter() body_t0 = time.perf_counter()