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 }