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
+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>