Refine movie detail cast rail and nav behavior

This commit is contained in:
2026-05-25 16:18:35 +00:00
parent ab1dfffdf3
commit fbf35f7242
2 changed files with 198 additions and 22 deletions
+97 -16
View File
@@ -92,7 +92,8 @@
:best="index === 0" :best="index === 0"
:selectable="!!version.playable_file" :selectable="!!version.playable_file"
:disabled="!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)" @activate="handleVersionActivate(version, $event)"
@keydown="handleVersionShortcutKeydown($event, version)" @keydown="handleVersionShortcutKeydown($event, version)"
@contextmenu="handleVersionContextMenu($event, version)" @contextmenu="handleVersionContextMenu($event, version)"
@@ -105,12 +106,18 @@
</div> </div>
</div> </div>
<div v-if="limitedMovieCast.length > 0" class="cast-list" data-sync-scroll-row="true"> <div
v-if="limitedMovieCast.length > 0"
class="cast-list"
data-sync-scroll-row="true"
data-nav-cast-row="true"
>
<a <a
v-for="(castMember, castIndex) in limitedMovieCast" v-for="(castMember, castIndex) in limitedMovieCast"
:key="`${castMember.name}-${castMember.character || ''}`" :key="`${castMember.name}-${castMember.character || ''}`"
class="cast-card media-card" class="cast-card media-card"
v-bind="navAttrs(2, movieVersions.length + castIndex)" data-nav-cast-item="true"
v-bind="navAttrs(2 + movieVersions.length, castIndex)"
:href="`#/?q=${encodeURIComponent(castMember.name)}`" :href="`#/?q=${encodeURIComponent(castMember.name)}`"
:title="`Search for ${castMember.name}`" :title="`Search for ${castMember.name}`"
@click.prevent="handleCastSelect(castMember.name)" @click.prevent="handleCastSelect(castMember.name)"
@@ -208,7 +215,11 @@ import castPlaceholderMaleUrl from "../assets/cast-placeholder-male.svg"
import SeriesFullView from "./SeriesFullView.vue" import SeriesFullView from "./SeriesFullView.vue"
import ReleaseVersionCard from "./ReleaseVersionCard.vue" import ReleaseVersionCard from "./ReleaseVersionCard.vue"
import ReleaseActionMenu from "./ReleaseActionMenu.vue" import ReleaseActionMenu from "./ReleaseActionMenu.vue"
import { navAttrs } from "../composables/useKeyboardNavigation" import {
navAttrs,
registerOutOfBoundsNavigationHandler,
FOCUSABLE_ATTR,
} from "../composables/useKeyboardNavigation"
const props = defineProps<{ const props = defineProps<{
item: MediaItem item: MediaItem
@@ -230,6 +241,64 @@ const videoStates = ref<string[]>([])
const COLLAGE_SLOT_COUNT = 5 const COLLAGE_SLOT_COUNT = 5
const safariAutoplay = isSafariBrowser() const safariAutoplay = isSafariBrowser()
const COLLAGE_START_OFFSETS_SECONDS = [0, 8, 6, 4, 2] 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<HTMLElement>('[data-nav-cast-row="true"]')
if (!castRow) return null
return castRow.querySelector<HTMLElement>(`[data-nav-cast-item="true"][${FOCUSABLE_ATTR}="true"]`)
}
function getReleaseAtRow(row: number): HTMLElement | null {
return document.querySelector<HTMLElement>(
`[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<HTMLElement>('[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) { function setVideoRef(el: HTMLVideoElement | null, index: number) {
videoRefs.value[index] = el videoRefs.value[index] = el
@@ -340,6 +409,8 @@ onMounted(() => {
setTimeout(() => { setTimeout(() => {
startStaggeredPlayback() startStaggeredPlayback()
}, 100) }, 100)
registerMovieOutOfBoundsShortcut()
}) })
const showreelSourceSets = computed((): string[][] | null => { const showreelSourceSets = computed((): string[][] | null => {
@@ -648,6 +719,9 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", handleMovieMenuKeydown, true) document.removeEventListener("keydown", handleMovieMenuKeydown, true)
disposeOutOfBoundsHandler?.()
disposeOutOfBoundsHandler = null
lastReleaseShortcutRow = null
}) })
</script> </script>
@@ -736,10 +810,14 @@ onUnmounted(() => {
.content-main { .content-main {
grid-area: main; grid-area: main;
min-width: 0; min-width: 0;
position: relative;
z-index: 2;
} }
.content-sidebar { .content-sidebar {
min-width: 0; min-width: 0;
position: relative;
z-index: 2;
} }
.sidebar-left { .sidebar-left {
@@ -955,16 +1033,18 @@ onUnmounted(() => {
} }
.cast-list { .cast-list {
--sync-row-tail: 0px;
--cast-safe-start: 40vw;
--cast-safe-end: 32px;
--sync-row-right-deadzone: 32px;
position: absolute; position: absolute;
top: calc(-1 * (var(--header-height) + 30px)); top: calc(-1 * (var(--header-height) + 30px));
left: calc(-50vw + 50% + 40vw - 0.8rem); left: calc(-50vw + 50%);
width: calc(100vw - (40vw - 0.8rem)); width: 100vw;
margin: 0; margin: 0;
padding-top: 4px; padding: 4px calc(var(--cast-safe-end) + var(--sync-row-tail)) 8px
padding-right: 32px; calc(var(--cast-safe-start) + var(--sync-row-tail));
padding-bottom: 8px; z-index: 0;
padding-left: 0;
z-index: 2;
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 6px; gap: 6px;
@@ -1093,14 +1173,15 @@ html:not(.mouse-active) .cast-card.nav-focused::after {
} }
.cast-list { .cast-list {
position: static; --cast-safe-start: 32px;
--cast-safe-end: 32px;
--sync-row-right-deadzone: 32px;
position: relative;
top: auto; top: auto;
left: auto; left: -32px;
width: auto; width: calc(100% + 64px);
margin-left: 0; margin-left: 0;
margin-top: 0; margin-top: 0;
padding-left: 0;
padding-right: 0;
} }
} }
@@ -6,6 +6,20 @@ export interface FocusableElement {
col: number col: number
} }
export type NavDirection = "up" | "down" | "left" | "right"
export interface OutOfBoundsNavigationContext {
current: HTMLElement
direction: NavDirection
currentRow: number
currentCol: number
byRow: Map<number, FocusableElement[]>
}
export type OutOfBoundsNavigationHandler = (
context: OutOfBoundsNavigationContext,
) => HTMLElement | null | undefined
// Global focus state // Global focus state
const focusedElement = ref<HTMLElement | null>(null) const focusedElement = ref<HTMLElement | null>(null)
const isNavigating = ref(false) 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_DEADZONE_RATIO = 0.18
const SYNC_SCROLL_EASING_MS = 220 const SYNC_SCROLL_EASING_MS = 220
const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail" const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail"
const SYNC_SCROLL_RIGHT_DEADZONE_VAR = "--sync-row-right-deadzone"
let syncedRowsFrame: number | null = null let syncedRowsFrame: number | null = null
let syncedRowsCurrentOffset = 0 let syncedRowsCurrentOffset = 0
@@ -32,6 +47,18 @@ let syncedRowsTargetOffset = 0
let syncedRowsTailPx = 0 let syncedRowsTailPx = 0
let lastSyncedAnchorCol: number | null = null let lastSyncedAnchorCol: number | null = null
let lastSyncedRowsAnimationAt: number | null = null let lastSyncedRowsAnimationAt: number | null = null
const outOfBoundsHandlers = new Set<OutOfBoundsNavigationHandler>()
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[] { function getSyncedRows(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)) return Array.from(document.querySelectorAll<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`))
@@ -48,6 +75,8 @@ function getSyncedRowMetrics(rows: HTMLElement[]) {
const rowStyle = window.getComputedStyle(row) const rowStyle = window.getComputedStyle(row)
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0") 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") let gap = parseFloat(rowStyle.columnGap || rowStyle.gap || "0")
if (cards.length > 1) { if (cards.length > 1) {
@@ -60,6 +89,9 @@ function getSyncedRowMetrics(rows: HTMLElement[]) {
stride: cardWidth + gap, stride: cardWidth + gap,
paddingLeft, paddingLeft,
viewportWidth: row.clientWidth, 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.paddingLeft,
(metrics.viewportWidth - metrics.cardWidth) * SYNC_SCROLL_DEADZONE_RATIO, (metrics.viewportWidth - metrics.cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
) )
const rightDeadzone = metrics.rightDeadzoneOverride ?? deadzoneInset
const minVisibleLeft = deadzoneInset const minVisibleLeft = deadzoneInset
const maxVisibleLeft = Math.max( const maxVisibleLeft = Math.max(
minVisibleLeft, minVisibleLeft,
metrics.viewportWidth - metrics.cardWidth - deadzoneInset, metrics.viewportWidth - metrics.cardWidth - rightDeadzone,
) )
const itemLeft = metrics.paddingLeft + anchorCol * metrics.stride const itemLeft = metrics.paddingLeft + anchorCol * metrics.stride
const viewportLeft = itemLeft - effectiveAnchorOffset 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<HTMLElement>(`.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) { function syncRowsToElement(element: HTMLElement) {
const row = parseInt(element.getAttribute(ROW_ATTR) || "0", 10) const row = parseInt(element.getAttribute(ROW_ATTR) || "0", 10)
if (row < SYNC_SCROLL_FIRST_CONTENT_ROW) { 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 currentCol = parseInt(element.getAttribute(COL_ATTR) || "0", 10)
const anchorCol = desiredCol.value ?? currentCol
const anchorRow = element.closest<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`) const anchorRow = element.closest<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)
if (!anchorRow) {
resetSyncedRows()
return
}
const requestedCol = desiredCol.value ?? currentCol
const anchorCol = getLocalSyncedRowCol(anchorRow, element, requestedCol)
updateSyncedRowTarget(anchorCol, anchorRow) updateSyncedRowTarget(anchorCol, anchorRow)
} }
@@ -414,7 +477,7 @@ function findElementClosestToLogicalViewportX(
*/ */
function findNextElement( function findNextElement(
current: HTMLElement, current: HTMLElement,
direction: "up" | "down" | "left" | "right", direction: NavDirection,
): HTMLElement | null { ): HTMLElement | null {
const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10) const currentRow = parseInt(current.getAttribute(ROW_ATTR) || "0", 10)
const currentCol = parseInt(current.getAttribute(COL_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 // Find element with target col in this row
const target = rowElements.find((e) => e.col === targetCol) 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 { } else {
// Vertical: move to adjacent row, try to maintain column // Vertical: move to adjacent row, try to maintain column
const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b) const sortedRows = Array.from(byRow.keys()).sort((a, b) => a - b)
@@ -443,7 +514,15 @@ function findNextElement(
const delta = direction === "down" ? 1 : -1 const delta = direction === "down" ? 1 : -1
const targetRowIdx = currentRowIdx + delta 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] const targetRow = sortedRows[targetRowIdx]
@@ -480,7 +559,16 @@ function findNextElement(
targetCol, targetCol,
metrics, 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 return attrs
} }
export function registerOutOfBoundsNavigationHandler(handler: OutOfBoundsNavigationHandler) {
outOfBoundsHandlers.add(handler)
return () => {
outOfBoundsHandlers.delete(handler)
}
}
export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR } export { FOCUSABLE_ATTR, ROW_ATTR, COL_ATTR, ENTRY_COL_ATTR }