From fbf35f724280802c8f9fb8a3addc21639be9ebbe Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 25 May 2026 16:18:35 +0000 Subject: [PATCH] Refine movie detail cast rail and nav behavior --- frontend/src/components/MediaDetail.vue | 113 +++++++++++++++--- .../src/composables/useKeyboardNavigation.ts | 107 ++++++++++++++++- 2 files changed, 198 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index eb4fa26..3a1956d 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -92,7 +92,8 @@ :best="index === 0" :selectable="!!version.playable_file" :disabled="!version.playable_file" - v-bind="navAttrs(2, index, index === 0 ? 0 : undefined)" + data-nav-release-item="true" + v-bind="navAttrs(2 + index, 0, 0)" @activate="handleVersionActivate(version, $event)" @keydown="handleVersionShortcutKeydown($event, version)" @contextmenu="handleVersionContextMenu($event, version)" @@ -105,12 +106,18 @@ -
+
([]) const COLLAGE_SLOT_COUNT = 5 const safariAutoplay = isSafariBrowser() const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2] +const DESKTOP_NAV_SHORTCUT_MIN_WIDTH = 900 + +let disposeOutOfBoundsHandler: (() => void) | null = null +let lastReleaseShortcutRow: number | null = null + +function getDesktopCastFirstItem(): HTMLElement | null { + const castRow = document.querySelector('[data-nav-cast-row="true"]') + if (!castRow) return null + return castRow.querySelector(`[data-nav-cast-item="true"][${FOCUSABLE_ATTR}="true"]`) +} + +function getReleaseAtRow(row: number): HTMLElement | null { + return document.querySelector( + `[data-nav-release-item="true"][data-nav-row="${row}"][data-nav-col="0"][${FOCUSABLE_ATTR}="true"]`, + ) +} + +function getLastReleaseRowBefore(castRow: number): number | null { + const releases = Array.from(document.querySelectorAll('[data-nav-release-item="true"]')) + let best: number | null = null + for (const release of releases) { + const row = parseInt(release.getAttribute("data-nav-row") || "", 10) + if (!Number.isFinite(row) || row >= castRow) continue + if (best === null || row > best) best = row + } + return best +} + +function registerMovieOutOfBoundsShortcut() { + disposeOutOfBoundsHandler?.() + disposeOutOfBoundsHandler = registerOutOfBoundsNavigationHandler((context) => { + if (window.innerWidth <= DESKTOP_NAV_SHORTCUT_MIN_WIDTH) return null + if (props.item.type !== "movies") return null + + const { current, direction, currentRow, currentCol } = context + + if (direction === "right" && current.hasAttribute("data-nav-release-item")) { + const firstCast = getDesktopCastFirstItem() + if (firstCast) { + lastReleaseShortcutRow = currentRow + return firstCast + } + return null + } + + if ( + direction === "left" && + current.hasAttribute("data-nav-cast-item") && + currentCol === 0 + ) { + const targetRow = lastReleaseShortcutRow ?? getLastReleaseRowBefore(currentRow) + if (targetRow === null) return null + return getReleaseAtRow(targetRow) + } + + return null + }) +} function setVideoRef(el: HTMLVideoElement | null, index: number) { videoRefs.value[index] = el @@ -340,6 +409,8 @@ onMounted(() => { setTimeout(() => { startStaggeredPlayback() }, 100) + + registerMovieOutOfBoundsShortcut() }) const showreelSourceSets = computed((): string[][] | null => { @@ -648,6 +719,9 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener("keydown", handleMovieMenuKeydown, true) + disposeOutOfBoundsHandler?.() + disposeOutOfBoundsHandler = null + lastReleaseShortcutRow = null }) @@ -736,10 +810,14 @@ onUnmounted(() => { .content-main { grid-area: main; min-width: 0; + position: relative; + z-index: 2; } .content-sidebar { min-width: 0; + position: relative; + z-index: 2; } .sidebar-left { @@ -955,16 +1033,18 @@ onUnmounted(() => { } .cast-list { + --sync-row-tail: 0px; + --cast-safe-start: 40vw; + --cast-safe-end: 32px; + --sync-row-right-deadzone: 32px; position: absolute; top: calc(-1 * (var(--header-height) + 30px)); - left: calc(-50vw + 50% + 40vw - 0.8rem); - width: calc(100vw - (40vw - 0.8rem)); + left: calc(-50vw + 50%); + width: 100vw; margin: 0; - padding-top: 4px; - padding-right: 32px; - padding-bottom: 8px; - padding-left: 0; - z-index: 2; + padding: 4px calc(var(--cast-safe-end) + var(--sync-row-tail)) 8px + calc(var(--cast-safe-start) + var(--sync-row-tail)); + z-index: 0; display: flex; flex-wrap: nowrap; gap: 6px; @@ -1093,14 +1173,15 @@ html:not(.mouse-active) .cast-card.nav-focused::after { } .cast-list { - position: static; + --cast-safe-start: 32px; + --cast-safe-end: 32px; + --sync-row-right-deadzone: 32px; + position: relative; top: auto; - left: auto; - width: auto; + left: -32px; + width: calc(100% + 64px); margin-left: 0; margin-top: 0; - padding-left: 0; - padding-right: 0; } } diff --git a/frontend/src/composables/useKeyboardNavigation.ts b/frontend/src/composables/useKeyboardNavigation.ts index 9a31ddb..3d94858 100644 --- a/frontend/src/composables/useKeyboardNavigation.ts +++ b/frontend/src/composables/useKeyboardNavigation.ts @@ -6,6 +6,20 @@ export interface FocusableElement { col: number } +export type NavDirection = "up" | "down" | "left" | "right" + +export interface OutOfBoundsNavigationContext { + current: HTMLElement + direction: NavDirection + currentRow: number + currentCol: number + byRow: Map +} + +export type OutOfBoundsNavigationHandler = ( + context: OutOfBoundsNavigationContext, +) => HTMLElement | null | undefined + // Global focus state const focusedElement = ref(null) const isNavigating = ref(false) @@ -25,6 +39,7 @@ const SYNC_SCROLL_FIRST_CONTENT_ROW = 2 const SYNC_SCROLL_DEADZONE_RATIO = 0.18 const SYNC_SCROLL_EASING_MS = 220 const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail" +const SYNC_SCROLL_RIGHT_DEADZONE_VAR = "--sync-row-right-deadzone" let syncedRowsFrame: number | null = null let syncedRowsCurrentOffset = 0 @@ -32,6 +47,18 @@ let syncedRowsTargetOffset = 0 let syncedRowsTailPx = 0 let lastSyncedAnchorCol: number | null = null let lastSyncedRowsAnimationAt: number | null = null +const outOfBoundsHandlers = new Set() + +function resolveOutOfBoundsNavigation( + context: OutOfBoundsNavigationContext, +): HTMLElement | null { + const handlers = Array.from(outOfBoundsHandlers) + for (let i = handlers.length - 1; i >= 0; i--) { + const result = handlers[i]?.(context) + if (result) return result + } + return null +} function getSyncedRows(): HTMLElement[] { return Array.from(document.querySelectorAll(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)) @@ -48,6 +75,8 @@ function getSyncedRowMetrics(rows: HTMLElement[]) { const rowStyle = window.getComputedStyle(row) const paddingLeft = parseFloat(rowStyle.paddingLeft || "0") + const rightDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_RIGHT_DEADZONE_VAR).trim() + const rightDeadzoneOverride = rightDeadzoneRaw ? parseFloat(rightDeadzoneRaw) : NaN let gap = parseFloat(rowStyle.columnGap || rowStyle.gap || "0") if (cards.length > 1) { @@ -60,6 +89,9 @@ function getSyncedRowMetrics(rows: HTMLElement[]) { stride: cardWidth + gap, paddingLeft, viewportWidth: row.clientWidth, + rightDeadzoneOverride: Number.isFinite(rightDeadzoneOverride) + ? Math.max(0, rightDeadzoneOverride) + : null, } } @@ -180,10 +212,11 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null metrics.paddingLeft, (metrics.viewportWidth - metrics.cardWidth) * SYNC_SCROLL_DEADZONE_RATIO, ) + const rightDeadzone = metrics.rightDeadzoneOverride ?? deadzoneInset const minVisibleLeft = deadzoneInset const maxVisibleLeft = Math.max( minVisibleLeft, - metrics.viewportWidth - metrics.cardWidth - deadzoneInset, + metrics.viewportWidth - metrics.cardWidth - rightDeadzone, ) const itemLeft = metrics.paddingLeft + anchorCol * metrics.stride const viewportLeft = itemLeft - effectiveAnchorOffset @@ -226,6 +259,30 @@ function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null } } +function getLocalSyncedRowCol( + anchorRow: HTMLElement, + element: HTMLElement, + requestedCol: number, +): number { + const cards = Array.from(anchorRow.querySelectorAll(`.media-card[${FOCUSABLE_ATTR}]`)) + if (cards.length === 0) return Math.max(0, requestedCol) + + const cardCols = cards + .map((card) => parseInt(card.getAttribute(COL_ATTR) || "", 10)) + .filter((col) => Number.isFinite(col)) + + if (cardCols.length > 0) { + const minCol = Math.min(...cardCols) + const localFromRequested = requestedCol - minCol + return Math.max(0, Math.min(localFromRequested, cards.length - 1)) + } + + const fallbackIndex = cards.indexOf(element) + if (fallbackIndex >= 0) return fallbackIndex + + return Math.max(0, Math.min(requestedCol, cards.length - 1)) +} + function syncRowsToElement(element: HTMLElement) { const row = parseInt(element.getAttribute(ROW_ATTR) || "0", 10) if (row < SYNC_SCROLL_FIRST_CONTENT_ROW) { @@ -234,8 +291,14 @@ function syncRowsToElement(element: HTMLElement) { } const currentCol = parseInt(element.getAttribute(COL_ATTR) || "0", 10) - const anchorCol = desiredCol.value ?? currentCol const anchorRow = element.closest(`[${SYNC_SCROLL_ROW_ATTR}="true"]`) + if (!anchorRow) { + resetSyncedRows() + return + } + + const requestedCol = desiredCol.value ?? currentCol + const anchorCol = getLocalSyncedRowCol(anchorRow, element, requestedCol) updateSyncedRowTarget(anchorCol, anchorRow) } @@ -414,7 +477,7 @@ function findElementClosestToLogicalViewportX( */ function findNextElement( current: HTMLElement, - direction: "up" | "down" | "left" | "right", + direction: NavDirection, ): HTMLElement | null { const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10) const currentCol = parseInt(current.getAttribute(COL_ATTR) || "0", 10) @@ -432,7 +495,15 @@ function findNextElement( // Find element with target col in this row const target = rowElements.find((e) => e.col === targetCol) - return target?.element || null + if (target?.element) return target.element + + return resolveOutOfBoundsNavigation({ + current, + direction, + currentRow, + currentCol, + byRow, + }) } else { // Vertical: move to adjacent row, try to maintain column const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b) @@ -443,7 +514,15 @@ function findNextElement( const delta = direction === "down" ? 1 : -1 const targetRowIdx = currentRowIdx + delta - if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) return null + if (targetRowIdx < 0 || targetRowIdx >= sortedRows.length) { + return resolveOutOfBoundsNavigation({ + current, + direction, + currentRow, + currentCol, + byRow, + }) + } const targetRow = sortedRows[targetRowIdx] @@ -480,7 +559,16 @@ function findNextElement( targetCol, metrics, ) - return closestByViewport?.element || entryTarget?.element || null + const resolved = closestByViewport?.element || entryTarget?.element || null + if (resolved) return resolved + + return resolveOutOfBoundsNavigation({ + current, + direction, + currentRow, + currentCol, + byRow, + }) } } @@ -714,4 +802,11 @@ export function navAttrs(row: number, col: number, entryCol?: number) { return attrs } +export function registerOutOfBoundsNavigationHandler(handler: OutOfBoundsNavigationHandler) { + outOfBoundsHandlers.add(handler) + return () => { + outOfBoundsHandlers.delete(handler) + } +} + export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR }