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) {
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)
}
+96 -5
View File
@@ -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<void> {
const normalizedPath = normalizeMediaPath(filePath)
const body: Record<string, unknown> = { 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<void> {
export async function openFolder(
rootId: string,
folderPath: string,
timing?: ActionTimingContext,
): Promise<void> {
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}`)
}
}
+43
View File
@@ -225,6 +225,7 @@
:play-label="getPlayLabel(versionActionMenu.filePath)"
@play="handlePlayVersion(versionActionMenu.filePath)"
@open-folder="handleOpenFolder(versionActionMenu.filePath || '', versionActionMenu.rootId)"
@close="closeVersionActionMenu"
/>
</Teleport>
</div>
@@ -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
+47 -1
View File
@@ -1,5 +1,12 @@
<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">
{{ resolvedPath }}
</div>
@@ -47,6 +54,7 @@ const props = withDefaults(
const emit = defineEmits<{
play: []
openFolder: []
close: []
}>()
const menuRef = ref<HTMLElement | null>(null)
@@ -73,6 +81,44 @@ const menuStyle = computed(() => ({
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() {
const menu = menuRef.value
if (!menu) return
+31 -14
View File
@@ -3,12 +3,13 @@
class="version-row"
:class="{
'version-best': best,
'version-selectable': isSelectable,
'version-selectable': isSelectable && !inertCard,
'version-disabled': isDisabled,
'version-menu': variant === 'menu',
'version-with-actions': showActions,
'version-inert': inertCard,
}"
tabindex="0"
:tabindex="inertCard ? undefined : 0"
:title="resolvedTitle"
v-bind="$attrs"
@click="handleActivate"
@@ -76,14 +77,16 @@
tabindex="0"
@click.stop="emit('play')"
:disabled="!torrent.playable_file"
:title="playLabel"
>
{{ playLabel }}
</button>
<button
class="ctx-btn ctx-btn-folder"
tabindex="0"
@click.stop="emit('openFolder')"
:disabled="!torrent.playable_file"
title="Open Folder"
>
📁
</button>
@@ -123,6 +126,9 @@ const props = withDefaults(
playLabel?: string
title?: string
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,
@@ -133,6 +139,7 @@ const props = withDefaults(
playLabel: "Play",
title: undefined,
variant: "default",
inertCard: false,
},
)
@@ -354,7 +361,7 @@ const resolvedTitle = computed(() => {
})
function handleActivate(event: MouseEvent | KeyboardEvent) {
if (!isSelectable.value || isDisabled.value) return
if (props.inertCard || !isSelectable.value || isDisabled.value) return
emit("activate", event)
}
</script>
@@ -409,6 +416,15 @@ html.mouse-active .version-row.version-best:hover {
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 {
cursor: not-allowed;
opacity: 0.75;
@@ -607,31 +623,32 @@ html.mouse-active .version-row.version-best:hover {
}
.ctx-btn {
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
color: #fff;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.65);
border-radius: 6px;
padding: 6px 10px;
font-size: 0.78rem;
padding: 4px 8px;
font-size: 2em;
line-height: 1;
cursor: pointer;
transition: color 0.15s ease;
}
html.mouse-active .ctx-btn:hover:not(:disabled),
html:not(.mouse-active) .ctx-btn.nav-focused:not(:disabled),
.ctx-btn:focus-visible:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.35);
color: #fff;
outline: none;
}
.ctx-btn:disabled {
opacity: 0.5;
opacity: 0.35;
cursor: not-allowed;
}
.ctx-btn-folder {
width: 34px;
width: auto;
text-align: center;
padding: 6px 0;
padding: 4px 8px;
}
</style>
+65 -249
View File
@@ -167,51 +167,24 @@
</div>
</section>
<!-- Context menu -->
<!-- Episode release menu -->
<Teleport to="body">
<div
v-if="contextMenu.visible"
class="context-menu-backdrop"
@click="closeContextMenu"
@contextmenu.prevent="closeContextMenu"
v-if="episodeReleaseMenu.visible"
class="episode-release-menu-backdrop"
@click="closeEpisodeReleaseMenu"
@contextmenu.prevent="closeEpisodeReleaseMenu"
></div>
<div
v-if="contextMenu.visible && contextMenu.episode"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div class="context-menu-header">
{{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }}
</div>
<div v-if="Object.values(contextMenu.episode.files || {}).length > 0">
<ReleaseVersionCard
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)"
<EpisodeReleaseMenu
:visible="episodeReleaseMenu.visible"
:x="episodeReleaseMenu.x"
:y="episodeReleaseMenu.y"
:episode-name="episodeReleaseMenu.episode?.name || `Episode ${episodeReleaseMenu.episode?.episode_number}`"
:releases="episodeReleaseMenuReleases"
:has-resume-position="props.hasResumePosition"
@play="handlePlayVersion"
@open-folder="handleOpenFolderFromMenu"
@close="closeEpisodeReleaseMenu"
/>
</Teleport>
</div>
@@ -219,11 +192,10 @@
<script setup lang="ts">
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 { navAttrs } from "../composables/useKeyboardNavigation"
import ReleaseVersionCard from "./ReleaseVersionCard.vue"
import ReleaseActionMenu from "./ReleaseActionMenu.vue"
import { navAttrs, setModalOpen } from "../composables/useKeyboardNavigation"
import EpisodeReleaseMenu from "./EpisodeReleaseMenu.vue"
import { sortTorrentsByPreference } from "../composables/useSettings"
const props = defineProps<{
@@ -486,8 +458,8 @@ watch(
{ immediate: true },
)
// Context menu state
const contextMenu = ref<{
// Episode release menu state (single-layer menu for all releases)
const episodeReleaseMenu = ref<{
visible: boolean
x: number
y: number
@@ -499,24 +471,13 @@ const contextMenu = ref<{
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 episodeReleaseMenuReleases = computed(() => {
if (!episodeReleaseMenu.value.episode) return []
return sortTorrentsByPreference(Object.values(episodeReleaseMenu.value.episode.files || {}))
})
function normalizeMatchText(value: string | null | undefined): string {
return (value || "")
.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) {
event.preventDefault()
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) {
closeVersionActionMenu()
contextMenu.value = {
setModalOpen(true)
episodeReleaseMenu.value = {
visible: true,
x,
y,
episode,
}
// Add Escape key listener (capturing phase to intercept before other handlers)
// Add Escape key listener as safety net (capture phase)
nextTick(() => {
document.addEventListener("keydown", handleContextMenuKeydown, true)
// Focus first selectable version card.
const firstCard = document.querySelector(
".context-menu .version-row.version-selectable",
) as HTMLElement
if (firstCard) {
firstCard.focus()
}
document.addEventListener("keydown", handleEpisodeMenuEscape, true)
})
}
@@ -616,102 +570,48 @@ function openEpisodeReleaseMenuFromElement(episode: Episode, element: HTMLElemen
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)
function handleContextMenuKeydown(event: KeyboardEvent) {
if (!contextMenu.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
}
// Capture-phase Escape handler as safety net for episode release menu
function handleEpisodeMenuEscape(event: KeyboardEvent) {
if (!episodeReleaseMenu.value.visible) return
if (event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
if (versionActionMenu.value.visible) {
closeVersionActionMenu()
return
}
closeContextMenu()
closeEpisodeReleaseMenu()
}
}
function getPopupFocusableElements(): HTMLElement[] {
const releaseItems = Array.from(
document.querySelectorAll<HTMLElement>(".context-menu .version-row.version-selectable"),
)
const actionItems = versionActionMenu.value.visible
? Array.from(
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)
// Close episode release menu
function closeEpisodeReleaseMenu() {
episodeReleaseMenu.value.visible = false
episodeReleaseMenu.value.episode = null
document.removeEventListener("keydown", handleEpisodeMenuEscape, true)
setModalOpen(false)
nextTick(() => {
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) {
const actionEvent = event as CustomEvent<{ action?: string }>
if (actionEvent.detail?.action !== "menu") return
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)
const episodeIndex = parseInt(active.getAttribute("data-episode-index") || "-1", 10)
if (seasonIndex < 0 || episodeIndex < 0) return
// Episode tile on series page
if (active.classList.contains("episode-tile")) {
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 episode = season?.episodes?.[episodeIndex]
if (!episode) return
const season = props.series.seasons?.[seasonIndex]
const episode = season?.episodes?.[episodeIndex]
if (!episode) return
actionEvent.preventDefault()
openEpisodeReleaseMenuFromElement(episode, active)
actionEvent.preventDefault()
openEpisodeReleaseMenuFromElement(episode, active)
return
}
}
// Play specific version
@@ -719,61 +619,18 @@ function handlePlayVersion(filePath: string | null) {
if (filePath) {
emit("play", filePath)
}
closeVersionActionMenu()
closeContextMenu()
closeEpisodeReleaseMenu()
}
function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? "Continue" : "Play"
}
// Open folder for a version
function handleOpenFolder(folderPath: string, rootId?: string | null) {
if (!folderPath) return
emit("openFolder", folderPath, rootId)
closeVersionActionMenu()
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()
})
// Open folder for a version from the episode release menu
function handleOpenFolderFromMenu(filePath: string) {
if (!filePath) return
const torrent = episodeReleaseMenu.value.episode?.files
? Object.values(episodeReleaseMenu.value.episode.files).find((t) => t.playable_file === filePath)
: undefined
const rootId = torrent?.root_id ?? props.series.root_id ?? null
emit("openFolder", filePath, rootId)
closeEpisodeReleaseMenu()
}
// Video refs for hover effects
@@ -1712,51 +1569,10 @@ html.mouse-active .episode-tile:hover .tile-play {
}
}
/* Context menu styles */
.context-menu-backdrop {
/* Episode release menu backdrop */
.episode-release-menu-backdrop {
position: fixed;
inset: 0;
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>
@@ -28,6 +28,8 @@ const activeNavigationScope = ref<string | null>(null)
const desiredCol = ref<number | null>(null)
// Track if global handlers are installed
let handlersInstalled = false
// Track open modal count — when > 0, global keyboard navigation is suspended
let modalOpenCount = 0
// Data attribute names
const FOCUSABLE_ATTR = "data-nav-focusable"
@@ -760,6 +762,8 @@ function shouldAllowNavigationFromInput(target: HTMLElement, direction: string):
}
function handleKeyDown(event: KeyboardEvent) {
if (modalOpenCount > 0) return
const target = event.target as HTMLElement
const direction = {
@@ -809,6 +813,8 @@ function handleKeyDown(event: KeyboardEvent) {
}
function handleEnterKey(event: KeyboardEvent) {
if (modalOpenCount > 0) return
if (event.key !== "Enter") return
if (event.defaultPrevented) 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() {
if (handlersInstalled) return
handlersInstalled = true
+9 -3
View File
@@ -300,7 +300,9 @@ class PlaybackStateCache:
class EventLoopLagMonitor:
"""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._window_seconds = window_seconds
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
if client_sent_ms_hdr:
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)
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
if client_sent_ms_hdr:
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)
body_t0 = time.perf_counter()