Clean up series nested context menus and implement keyboard/gamepad navigation for these functions.
This commit is contained in:
+10
-2
@@ -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
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,92 +570,36 @@ 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
|
||||
|
||||
// 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
|
||||
@@ -712,6 +610,8 @@ function handleGamepadAction(event: Event) {
|
||||
|
||||
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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user