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) {
|
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
@@ -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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user