From c3dee9bed9a2258094b72f67a18fe4563fb5bde8 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 22 May 2026 18:48:34 +0000 Subject: [PATCH] feat(series): improve release popup navigation and controls --- frontend/src/components/MediaDetail.vue | 248 +-------- .../src/components/ReleaseVersionCard.vue | 393 ++++++++++++++ frontend/src/components/SeriesFullView.vue | 513 ++++++++---------- .../src/composables/useGamepadNavigation.ts | 16 +- 4 files changed, 642 insertions(+), 528 deletions(-) create mode 100644 frontend/src/components/ReleaseVersionCard.vue diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index e37ef0d..7636f8f 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -87,49 +87,17 @@ >
-
-
-
- {{ version.resolution }} - {{ getDisplayQualityBadge(version) }} - {{ getDisplayCodecBadge(version) }} - HDR - {{ getDisplayAudioBadge(version) }} - 💿 -
-
- - - -
-
-
- -
-
+ />
@@ -192,10 +160,8 @@ import { getCoverUrl, getVideoPreviewUrl, getVideoSourceAttributes, isSafariBrow import castPlaceholderFemaleUrl from '../assets/cast-placeholder-female.svg'; import castPlaceholderMaleUrl from '../assets/cast-placeholder-male.svg'; import SeriesFullView from './SeriesFullView.vue'; -import LanguageFlags from './LanguageFlags.vue'; -import DolbyBadges from './DolbyBadges.vue'; +import ReleaseVersionCard from './ReleaseVersionCard.vue'; import { navAttrs } from '../composables/useKeyboardNavigation'; -import { buildLanguageFlags } from '../utils/languageFlags'; const props = defineProps<{ item: MediaItem; @@ -402,81 +368,6 @@ const synopsisPosterUrl = computed(() => { return getCoverUrl(props.item.cover_path); }); -const dolbyTagPattern = /\b(dolby|atmos|vision|dovi|dv)\b/i; -const dolbyVisionPattern = /\b(dolby\s*vision|dovi|\bdv\b)\b/i; -const dolbyAtmosPattern = /\b(dolby\s*atmos|atmos)\b/i; -const hdrPattern = /\bhdr\b|smpte\s*2084|bt\s*2020|hlg/i; -const blurayTagPattern = /\bblu[\s.-]*ray\b/i; - -function hasDolbyTag(value: string | null | undefined): boolean { - return Boolean(value && dolbyTagPattern.test(value)); -} - -function hasAnyTag( - pattern: RegExp, - ...values: Array -): boolean { - return values.some((value) => Boolean(value && pattern.test(value))); -} - -function getHasDolbyVision(version: Torrent): boolean { - return ( - version.has_dolby_vision === true - || hasAnyTag(dolbyVisionPattern, version.quality, version.codec, version.audio, version.title) - ); -} - -function getHasDolbyAtmos(version: Torrent): boolean { - return ( - version.has_dolby_atmos === true - || hasAnyTag(dolbyAtmosPattern, version.quality, version.codec, version.audio, version.title) - ); -} - -function getHasHdr(version: Torrent): boolean { - return ( - version.is_hdr === true - || hasAnyTag(hdrPattern, version.quality, version.codec, version.audio, version.title) - ); -} - -function getDisplayQualityBadge(version: Torrent): string | null { - if (!version.quality || hasDolbyTag(version.quality)) return null; - if (blurayTagPattern.test(version.quality) && !isVersionDisc(version)) return null; - return version.quality; -} - -function getDisplayCodecBadge(version: Torrent): string | null { - if (!version.codec || hasDolbyTag(version.codec)) return null; - return version.codec; -} - -function getDisplayAudioBadge(version: Torrent): string | null { - if (!version.audio || hasDolbyTag(version.audio)) return null; - return version.audio; -} - -function hasLanguageDisplay(codes: string[] | null | undefined): boolean { - const mapped = buildLanguageFlags(codes); - return mapped.flags.length > 0 || mapped.unmappedCodes.length > 0; -} - -function hasHdrTag(value: string | null | undefined): boolean { - return Boolean(value && hdrPattern.test(value)); -} - -function getShowHdrBadge(version: Torrent): boolean { - if (!getHasHdr(version)) return false; - return !hasHdrTag(version.quality) && !hasHdrTag(version.codec) && !hasHdrTag(version.audio); -} - -// Check if a specific version is a disc format (Blu-ray disc has index.bdmv) -function isVersionDisc(version: Torrent): boolean { - if (!version.playable_file) return false; - const filename = version.playable_file.toLowerCase(); - return filename.endsWith('index.bdmv') || filename.endsWith('.iso'); -} - const movieGenres = computed(() => { if (props.item.type !== 'movies') return null; return (props.item.data as Movie).info?.genres; @@ -1556,127 +1447,4 @@ function handleOpenFolder(folderPath: string) { gap: 8px; } -.versions-list-sidebar .version-row { - padding: 10px 12px; -} - -.version-row { - display: grid; - grid-template-columns: minmax(0, 1fr) max-content; - align-items: stretch; - column-gap: 8px; - row-gap: 6px; - padding: 12px 16px; - background: rgba(10, 14, 22, 0.2); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.1); - transition: background 0.2s, border-color 0.2s; -} - -.version-row:hover { - background: rgba(10, 14, 22, 0.28); - border-color: rgba(255, 255, 255, 0.2); -} - -.version-row.version-best { - border-color: rgba(255, 255, 255, 0.1); - background: rgba(10, 14, 22, 0.2); -} - -.version-row.version-best:hover { - background: rgba(10, 14, 22, 0.28); - border-color: rgba(255, 255, 255, 0.2); -} - -.version-row.version-selectable { - cursor: pointer; -} - -.version-row.version-selectable:focus-visible { - outline: 2px solid rgba(255, 255, 255, 0.85); - outline-offset: 2px; -} - -.version-row.version-disabled { - cursor: not-allowed; - opacity: 0.75; -} - -.version-main { - grid-column: 1; - display: flex; - flex-direction: column; - justify-content: center; - gap: 6px; - min-width: 0; -} - -.version-dolby-cell { - grid-column: 2; - display: flex; - align-items: stretch; - justify-content: flex-end; - min-width: 0; -} - -.version-dolby { - align-self: stretch; -} - -.btn-small { - padding: 6px 12px; - font-size: 0.8rem; -} - -.version-badges { - display: flex; - flex-wrap: nowrap; - align-items: center; - gap: 6px; -} - -.v-badge { - display: flex; - align-items: center; - font-size: 0.7rem; - padding: 3px 8px; - border-radius: 4px; - font-weight: 600; - text-transform: uppercase; -} - -.v-disc { - display: flex; - align-items: center; - font-size: 1.1rem; - line-height: 1; -} - -.v-badge.res { - background: #1d4ed8; - color: #eff6ff; -} - -.v-badge.qual { - background: #7c3aed; - color: #f5f3ff; -} - -.v-badge.codec { - background: #0f766e; - color: #ecfeff; -} - -.v-badge.audio { - background: #b45309; - color: #fffbeb; -} - -.v-badge.hdr { - background: #166534; - color: #dcfce7; -} - diff --git a/frontend/src/components/ReleaseVersionCard.vue b/frontend/src/components/ReleaseVersionCard.vue new file mode 100644 index 0000000..2011256 --- /dev/null +++ b/frontend/src/components/ReleaseVersionCard.vue @@ -0,0 +1,393 @@ + + + + + diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 3e5b8b0..44dc658 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -67,7 +67,7 @@ tabindex="0" v-bind="navAttrs(sIndex + 2, eIndex)" @click="handlePlay(episode)" - @keydown.enter.prevent="handlePlay(episode)" + @keydown.enter.prevent="handleEpisodeEnter($event, episode)" @mouseenter="handleEpisodeHover(`${sIndex}-${eIndex}`, true)" @mouseleave="handleEpisodeHover(`${sIndex}-${eIndex}`, false)" @contextmenu="handleContextMenu($event, episode)" @@ -133,61 +133,53 @@ {{ contextMenu.episode.name || `Episode ${contextMenu.episode.episode_number}` }}
-
-
-
{{ getVersionLabel(torrent) }}
-
- - - -
-
-
- -
-
- - -
-
+ :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)" + />
No versions available
+
+ + +
diff --git a/frontend/src/composables/useGamepadNavigation.ts b/frontend/src/composables/useGamepadNavigation.ts index b4d51b7..e3d967e 100644 --- a/frontend/src/composables/useGamepadNavigation.ts +++ b/frontend/src/composables/useGamepadNavigation.ts @@ -1,9 +1,9 @@ -type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back'; +type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back' | 'menu'; const GAMEPAD_AXIS_THRESHOLD = 0.55; const GAMEPAD_REPEAT_MS = 180; -const KEY_BY_ACTION: Record = { +const KEY_BY_ACTION: Partial> = { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', @@ -19,6 +19,7 @@ const gamepadPressedState: Record = { right: false, select: false, back: false, + menu: false, }; const gamepadLastTriggerAt: Record = { @@ -28,6 +29,7 @@ const gamepadLastTriggerAt: Record = { right: 0, select: 0, back: 0, + menu: 0, }; let gamepadFrameId: number | null = null; @@ -61,7 +63,10 @@ function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: numb const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent); if (!shouldContinueWithKeyboard) return; - dispatchKey(KEY_BY_ACTION[action]); + const key = KEY_BY_ACTION[action]; + if (key) { + dispatchKey(key); + } } function resetPressedState() { @@ -71,6 +76,7 @@ function resetPressedState() { gamepadPressedState.right = false; gamepadPressedState.select = false; gamepadPressedState.back = false; + gamepadPressedState.menu = false; } function pollGamepad() { @@ -85,6 +91,7 @@ function pollGamepad() { let right = false; let select = false; let back = false; + let menu = false; for (const gamepad of connectedGamepads) { const axisX = gamepad.axes[0] ?? 0; @@ -98,6 +105,8 @@ function pollGamepad() { // Xbox mapping on standard gamepads: A=0, B=1 select = select || Boolean(gamepad.buttons[0]?.pressed); back = back || Boolean(gamepad.buttons[1]?.pressed); + // Y/Triangle button opens contextual release list where supported. + menu = menu || Boolean(gamepad.buttons[3]?.pressed); } applyGamepadAction('up', up, now); @@ -106,6 +115,7 @@ function pollGamepad() { applyGamepadAction('right', right, now); applyGamepadAction('select', select, now); applyGamepadAction('back', back, now); + applyGamepadAction('menu', menu, now); } else { resetPressedState(); }