Series detail: curtain poster browser for seasons
Replace the 3D ring jukebox with a flat curtain browser: selected season faces the screen as top of the left stack, side cards rotate about their outer edge and recede into the screen, each side evenly spaced with pitch adapting to its card count so cards never leave the stage. - Mount episode videos only after the season switch settles, then preload=auto + autoplay (avoids mid-animation media setup jank) - Start videos immediately (no stagger) once an episode is pointed at; keep staggered starts while navigating the season selector - Keyboard/gamepad entry into the season row lands on the selected season via data-nav-entry-col instead of the x-closest card - Black episode tiles; hidden (ahead-of-cursor) tiles get a sheen and vignette veil that cross-fades on reveal/conceal - Nudge season info panel 8px left for balanced gaps
This commit is contained in:
@@ -41,7 +41,7 @@
|
|||||||
<!-- Spacer for header overlay -->
|
<!-- Spacer for header overlay -->
|
||||||
<div class="header-spacer"></div>
|
<div class="header-spacer"></div>
|
||||||
|
|
||||||
<!-- Season selector jukebox: one season rendered at a time -->
|
<!-- Season poster browser: all seasons visible in two side stacks -->
|
||||||
<section class="seasons-container">
|
<section class="seasons-container">
|
||||||
<div
|
<div
|
||||||
ref="seasonStageRef"
|
ref="seasonStageRef"
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
class="season-poster-card"
|
class="season-poster-card"
|
||||||
:class="{ 'season-poster-card--selected': sIndex === selectedSeasonIndex }"
|
:class="{ 'season-poster-card--selected': sIndex === selectedSeasonIndex }"
|
||||||
:style="getSeasonCardStyle(sIndex)"
|
:style="getSeasonCardStyle(sIndex)"
|
||||||
v-bind="navAttrs(2, sIndex)"
|
v-bind="navAttrs(2, sIndex, selectedSeasonIndex)"
|
||||||
@click="selectSeason(sIndex)"
|
@click="selectSeason(sIndex)"
|
||||||
@focusin="selectSeason(sIndex)"
|
@focusin="selectSeason(sIndex)"
|
||||||
@keydown.enter.prevent="focusFirstEpisode"
|
@keydown.enter.prevent="focusFirstEpisode"
|
||||||
@@ -140,10 +140,10 @@
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<video
|
<video
|
||||||
v-if="getEpisodeVideoSources(episode).length > 0"
|
v-if="episodeMediaReady && getEpisodeVideoSources(episode).length > 0"
|
||||||
:ref="(el) => setVideoRef(el as HTMLVideoElement, `${eIndex}`)"
|
:ref="(el) => setVideoRef(el as HTMLVideoElement, `${eIndex}`)"
|
||||||
:autoplay="false"
|
autoplay
|
||||||
:preload="episodeMediaReady ? 'auto' : 'none'"
|
preload="auto"
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
@@ -295,10 +295,11 @@ function selectSeason(index: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Episode media settle gating: videos mount with preload="none" and stay
|
// Episode media settle gating: video elements are not mounted until the
|
||||||
// paused until the jukebox animation has settled, so switching seasons does
|
// season switch animation has settled, so switching seasons neither mounts
|
||||||
// not trigger a burst of video loads mid-transition. The episode still image
|
// media elements nor triggers video loads mid-transition (both heavy on
|
||||||
// underneath provides the preview picture in the meantime.
|
// Chrome). Once mounted they load and autoplay right away; the episode still
|
||||||
|
// image underneath provides the preview picture in the meantime.
|
||||||
const episodeMediaReady = ref(false)
|
const episodeMediaReady = ref(false)
|
||||||
const EPISODE_MEDIA_SETTLE_MS = 600
|
const EPISODE_MEDIA_SETTLE_MS = 600
|
||||||
let episodeMediaReadyTimer: ReturnType<typeof setTimeout> | null = null
|
let episodeMediaReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -348,12 +349,16 @@ function handleVideoPlaying(event: Event) {
|
|||||||
;(event.target as HTMLVideoElement | null)?.classList.add("is-playing")
|
;(event.target as HTMLVideoElement | null)?.classList.add("is-playing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jukebox stage: cards are placed at constant angular steps on a semicircular
|
// Poster browser stage: the selected season is the topmost item of the left
|
||||||
// arc of radius R around the selection — x = R·sin(θ), depth = R·(1−cos θ) —
|
// stack — flat, facing the screen — while every other season is rotated
|
||||||
// so the browser's perspective does a true 3D ring. Every season stays fully
|
// about its outer vertical edge so the inner edge recedes into the screen.
|
||||||
// opaque and visible; extreme cards pile up nearly edge-on at the arc cap but
|
// Left-side cards are anchored by their left edge and form an even ladder
|
||||||
// are never hidden. The selected card is shifted left of center, leaving the
|
// from the stage's left border up to the displayed card; right-side cards
|
||||||
// right side free for the floating season-info panel.
|
// are anchored by their right edge and fill from the info panel's gutter to
|
||||||
|
// the right border. Each side's pitch adjusts to its card count, so cards
|
||||||
|
// never leave the stage — they just overlap more tightly. Because rotated
|
||||||
|
// cards only recede from their hinge, the flat displayed card naturally
|
||||||
|
// stays in front no matter how tight a stack gets.
|
||||||
const seasonStageRef = ref<HTMLElement | null>(null)
|
const seasonStageRef = ref<HTMLElement | null>(null)
|
||||||
const stageWidth = ref(1280)
|
const stageWidth = ref(1280)
|
||||||
|
|
||||||
@@ -367,45 +372,67 @@ const STAGE_PERSPECTIVE_PX = 1200
|
|||||||
const stageMetrics = computed(() => {
|
const stageMetrics = computed(() => {
|
||||||
const width = stageWidth.value
|
const width = stageWidth.value
|
||||||
if (width <= 600) {
|
if (width <= 600) {
|
||||||
// Info panel floats below the poster here, so the ring stays centered.
|
// Info panel floats below the poster here, so the right stack only needs
|
||||||
return { posterWidth: 170, radius: 900, stepDeg: 16, rightStartDeg: 16, infoShift: 0, maxDeg: 80 }
|
// a plain gutter after the displayed poster.
|
||||||
|
return { posterWidth: 170, infoShift: 0, rightGutter: 30, angleDeg: 40 }
|
||||||
}
|
}
|
||||||
if (width <= 900) {
|
if (width <= 900) {
|
||||||
return { posterWidth: 230, radius: 1100, stepDeg: 14, rightStartDeg: 28, infoShift: 160, maxDeg: 80 }
|
return { posterWidth: 230, infoShift: 160, rightGutter: 282, angleDeg: 40 }
|
||||||
}
|
}
|
||||||
return { posterWidth: 290, radius: 1300, stepDeg: 13, rightStartDeg: 32, infoShift: 200, maxDeg: 80 }
|
return { posterWidth: 290, infoShift: 200, rightGutter: 370, angleDeg: 40 }
|
||||||
})
|
})
|
||||||
|
|
||||||
function getSeasonCardStyle(index: number): Record<string, string> {
|
function getSeasonCardStyle(index: number): Record<string, string> {
|
||||||
const offset = index - selectedSeasonIndex.value
|
const total = props.series.seasons.length
|
||||||
const absOffset = Math.abs(offset)
|
const selected = selectedSeasonIndex.value
|
||||||
|
const offset = index - selected
|
||||||
const metrics = stageMetrics.value
|
const metrics = stageMetrics.value
|
||||||
|
const posterW = metrics.posterWidth
|
||||||
|
const halfStage = stageWidth.value / 2
|
||||||
|
|
||||||
// Angular position on the arc. The right side starts past the opening
|
// The displayed card faces the screen, shifted left of center to free the
|
||||||
// reserved for the season-info panel; the cap keeps extreme cards from
|
// floating season-info panel on its right.
|
||||||
// turning fully edge-on.
|
const displayedLeft = -metrics.infoShift - posterW / 2
|
||||||
|
const displayedRight = displayedLeft + posterW
|
||||||
|
|
||||||
|
const angleRad = (metrics.angleDeg * Math.PI) / 180
|
||||||
|
const cos = Math.cos(angleRad)
|
||||||
|
const sin = Math.sin(angleRad)
|
||||||
|
// Perspective shrink at a rotated card's receded far edge (depth P·sin θ).
|
||||||
|
const farScale = STAGE_PERSPECTIVE_PX / (STAGE_PERSPECTIVE_PX + posterW * sin)
|
||||||
|
|
||||||
|
let anchor: number
|
||||||
let thetaDeg: number
|
let thetaDeg: number
|
||||||
if (offset === 0) {
|
let hingeShift: number
|
||||||
thetaDeg = 0
|
if (offset <= 0) {
|
||||||
} else if (offset > 0) {
|
// Left stack + displayed card: anchored by the left edge, hinge on the
|
||||||
thetaDeg = Math.min(metrics.rightStartDeg + (absOffset - 1) * metrics.stepDeg, metrics.maxDeg)
|
// left, right side receding into the screen. Anchors form an even ladder
|
||||||
|
// from the stage's left border up to the displayed card, so the
|
||||||
|
// displayed card keeps the same pitch as the stack it tops.
|
||||||
|
const leftCount = selected
|
||||||
|
const span = Math.max(0, displayedLeft + halfStage)
|
||||||
|
const step = leftCount > 0 ? span / leftCount : 0
|
||||||
|
anchor = displayedLeft - (leftCount - index) * step
|
||||||
|
thetaDeg = offset === 0 ? 0 : metrics.angleDeg
|
||||||
|
hingeShift = posterW / 2
|
||||||
} else {
|
} else {
|
||||||
thetaDeg = -Math.min(metrics.stepDeg * absOffset, metrics.maxDeg)
|
// Right stack: anchored by the right edge, hinge on the right, left side
|
||||||
|
// receding into the screen. The innermost card's receded edge clears the
|
||||||
|
// info panel; the outermost card's hinge may touch the right border.
|
||||||
|
const rightCount = total - selected - 1
|
||||||
|
const stackStart = displayedRight + metrics.rightGutter
|
||||||
|
const inner = Math.min(stackStart / farScale + posterW * cos, halfStage)
|
||||||
|
const step = rightCount > 1 ? (halfStage - inner) / (rightCount - 1) : 0
|
||||||
|
anchor = inner + (offset - 1) * step
|
||||||
|
thetaDeg = -metrics.angleDeg
|
||||||
|
hingeShift = -posterW / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
const theta = (thetaDeg * Math.PI) / 180
|
// All states share the same transform function list
|
||||||
const depth = metrics.radius * (1 - Math.cos(theta))
|
// (translateX/rotateY/translateX), so side switches interpolate smoothly.
|
||||||
const visualWidth =
|
|
||||||
metrics.posterWidth * (STAGE_PERSPECTIVE_PX / (STAGE_PERSPECTIVE_PX + depth))
|
|
||||||
|
|
||||||
// Keep every card's (perspective-shrunk) edge inside the stage.
|
|
||||||
const xLimit = Math.max(0, stageWidth.value / 2 - visualWidth / 2 - 8)
|
|
||||||
const rawX = -metrics.infoShift + metrics.radius * Math.sin(theta)
|
|
||||||
const translateX = Math.max(-xLimit, Math.min(rawX, xLimit))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transform: `translateX(-50%) translateX(${translateX}px) translateZ(${-depth}px) rotateY(${-thetaDeg}deg)`,
|
transform: `translateX(${anchor - posterW / 2}px) rotateY(${thetaDeg}deg) translateX(${hingeShift}px)`,
|
||||||
zIndex: String(100 - absOffset),
|
zIndex: String(100 - Math.abs(offset)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +544,10 @@ watch(
|
|||||||
const seasonIndex =
|
const seasonIndex =
|
||||||
props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1
|
props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1
|
||||||
if (seasonIndex < 0) return
|
if (seasonIndex < 0) return
|
||||||
selectedSeasonIndex.value = seasonIndex
|
if (selectedSeasonIndex.value !== seasonIndex) {
|
||||||
|
selectedSeasonIndex.value = seasonIndex
|
||||||
|
scheduleEpisodeMediaReady()
|
||||||
|
}
|
||||||
// Delay to ensure DOM is fully rendered after season switch / route transition
|
// Delay to ensure DOM is fully rendered after season switch / route transition
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -810,7 +840,7 @@ function pauseEpisodeVideo(video: HTMLVideoElement) {
|
|||||||
video.volume = 0
|
video.volume = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSeasonVideoPlayback(priorityKey?: string) {
|
function syncSeasonVideoPlayback() {
|
||||||
seasonStartupToken += 1
|
seasonStartupToken += 1
|
||||||
const token = seasonStartupToken
|
const token = seasonStartupToken
|
||||||
clearSeasonStartupTimers()
|
clearSeasonStartupTimers()
|
||||||
@@ -859,21 +889,16 @@ function syncSeasonVideoPlayback(priorityKey?: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
videosToStart.sort((a, b) => {
|
// With no episode pointed at (cursor still in the season selector), starts
|
||||||
if (priorityKey) {
|
// are staggered to spread the load. Once an episode is pointed at (hover
|
||||||
if (a.key === priorityKey) return -1
|
// or keyboard/gamepad focus), every video up to it starts immediately.
|
||||||
if (b.key === priorityKey) return 1
|
const immediateStart = episodeCursorIndex.value !== null
|
||||||
}
|
|
||||||
return a.episodeIndex - b.episodeIndex
|
videosToStart.sort((a, b) => a.episodeIndex - b.episodeIndex)
|
||||||
})
|
|
||||||
|
|
||||||
for (let i = 0; i < videosToStart.length; i += 1) {
|
for (let i = 0; i < videosToStart.length; i += 1) {
|
||||||
const { key, video } = videosToStart[i]
|
const { key, video } = videosToStart[i]
|
||||||
const delayMs = priorityKey
|
const delayMs = immediateStart ? 0 : i * SEASON_VIDEO_STARTUP_STEP_MS
|
||||||
? i === 0
|
|
||||||
? 0
|
|
||||||
: i * SEASON_VIDEO_STARTUP_STEP_MS
|
|
||||||
: i * SEASON_VIDEO_STARTUP_STEP_MS
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (token !== seasonStartupToken || previewPlaybackStopped.value) return
|
if (token !== seasonStartupToken || previewPlaybackStopped.value) return
|
||||||
if (safariAutoplay && video.readyState >= 1) {
|
if (safariAutoplay && video.readyState >= 1) {
|
||||||
@@ -1029,7 +1054,7 @@ function handleEpisodeHover(
|
|||||||
if (event?.currentTarget instanceof HTMLElement) {
|
if (event?.currentTarget instanceof HTMLElement) {
|
||||||
event.currentTarget.focus({ preventScroll: true })
|
event.currentTarget.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
syncSeasonVideoPlayback(key)
|
syncSeasonVideoPlayback()
|
||||||
hoveredEpisodeAudioKey = key
|
hoveredEpisodeAudioKey = key
|
||||||
videoRefs.value.forEach((_, k) => {
|
videoRefs.value.forEach((_, k) => {
|
||||||
if (k !== key) {
|
if (k !== key) {
|
||||||
@@ -1414,8 +1439,9 @@ watch(episodeCursorIndex, () => {
|
|||||||
padding: 0 0 60px;
|
padding: 0 0 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Season selector jukebox: selected season centered at full poster size,
|
/* Season poster browser: selected season faces the screen as top of the left
|
||||||
neighbours stack up scaled/rotated on both sides, clamped inside the stage */
|
stack; side cards rotate about their outer edge and recede into the screen,
|
||||||
|
evenly spaced per side so every card stays inside the stage */
|
||||||
.season-stage {
|
.season-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: calc(var(--poster-w, 290px) * 1.5 + 12px);
|
height: calc(var(--poster-w, 290px) * 1.5 + 12px);
|
||||||
@@ -1518,7 +1544,7 @@ html:not(.mouse-active) .season-poster-card.nav-focused,
|
|||||||
/* Season info floats in the space freed by shifting the selected poster left */
|
/* Season info floats in the space freed by shifting the selected poster left */
|
||||||
.season-info {
|
.season-info {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: calc(50% - var(--info-shift, 0px) + var(--poster-w, 290px) / 2 + 30px);
|
left: calc(50% - var(--info-shift, 0px) + var(--poster-w, 290px) / 2 + 22px);
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 320px;
|
width: 320px;
|
||||||
@@ -1602,7 +1628,7 @@ html:not(.mouse-active) .season-poster-card.nav-focused,
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: #000;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1679,8 +1705,8 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Videos stay hidden (and preload="none") until playback starts after the
|
/* Videos are only mounted after the season switch has settled; they stay
|
||||||
season switch has settled; they fade in softly over the still image. */
|
transparent until playback starts, then fade in softly over the still. */
|
||||||
.tile-media video {
|
.tile-media video {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1712,6 +1738,31 @@ html:not(.mouse-active) .episode-tile.nav-focused .tile-focus-outline rect {
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hidden tiles are not left as flat black slabs: a soft sheen-and-vignette
|
||||||
|
veil marks them as deliberately concealed. It cross-fades with the
|
||||||
|
content on hide/reveal via opacity. */
|
||||||
|
.episode-tile::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
115deg,
|
||||||
|
rgba(255, 255, 255, 0.09) 0%,
|
||||||
|
rgba(255, 255, 255, 0.025) 30%,
|
||||||
|
rgba(255, 255, 255, 0) 55%
|
||||||
|
),
|
||||||
|
linear-gradient(to bottom, rgba(20, 20, 28, 0.5) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.45) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-tile--ahead::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.ep-number {
|
.ep-number {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
@@ -1804,7 +1855,7 @@ html.mouse-active .episode-tile:hover .tile-play {
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.season-info {
|
.season-info {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
left: calc(50% - var(--info-shift, 0px) + var(--poster-w, 230px) / 2 + 22px);
|
left: calc(50% - var(--info-shift, 0px) + var(--poster-w, 230px) / 2 + 14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.season-info-name {
|
.season-info-name {
|
||||||
|
|||||||
Reference in New Issue
Block a user