diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2b6995b..6dc3b96 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1122,10 +1122,15 @@ function focusDetailEntryTarget(item: MediaItem): boolean { '[data-nav-release-item="true"][data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]', ) as HTMLElement | null } else if (item.type === "series") { - // Initial episode tile (first season, first episode) maps to row 2 / col 0. + // Row 2 is the season selector strip; land on the selected season poster. target = detailPanel.querySelector( - '.episode-tile[data-nav-row="2"][data-nav-col="0"][data-nav-focusable="true"]', + '.season-poster-card.season-poster-card--selected[data-nav-focusable="true"]', ) as HTMLElement | null + if (!target) { + target = detailPanel.querySelector( + '.episode-tile[data-nav-focusable="true"]', + ) as HTMLElement | null + } } if (target) { diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index 1fc7c9a..5f53819 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -883,14 +883,6 @@ const ratingClass = computed(() => { return "rating-low" }) -const seasons = computed(() => { - if (props.item.type !== "series") return [] - const series = props.item.data as Series - return series.seasons || [] -}) - -const selectedSeasonIndex = ref(0) - const versionActionMenu = ref<{ visible: boolean x: number @@ -977,17 +969,6 @@ function handleMovieMenuKeydown(event: KeyboardEvent) { } } -// Select first season by default -watch( - seasons, - (s) => { - if (s.length > 0 && selectedSeasonIndex.value >= s.length) { - selectedSeasonIndex.value = 0 - } - }, - { immediate: true }, -) - function handlePlay(filePath: string | null) { if (filePath) { emit("play", filePath) diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index 2540604..7db5168 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -41,101 +41,141 @@
- +
- -
-
+ +
+ {{ season.season_number }} +
+
+ {{ + season.name || `Season ${season.season_number}` + }} + {{ season.episode_count ?? season.episodes.length }} Episodes +
+ + + +
+ +
+

+ {{ selectedSeason.name || `Season ${selectedSeason.season_number}` }} +

+
+ {{ + formatDate(selectedSeason.air_date) + }} + {{ selectedSeason.episode_count ?? selectedSeason.episodes.length }} + Episodes +
+

+ {{ selectedSeason.overview }} +

+
+
+
+
+ + +
+
+ + + + + + +
+
-
- {{ season.season_number }} -
-
-
{{ season.name || `Season ${season.season_number}` }}
-
- {{ truncate(season.overview, 120) }} -
-
-
-
- - -
-
- - - - - -
- -
-
- - -
- - -
- {{ episode.episode_number }} -
- {{ - episode.name || `Episode ${episode.episode_number}` - }} - ★ {{ episode.rating.toFixed(1) }} -
-
- - + + {{ episode.episode_number }}
+ + +
+ {{ episode.name || `Episode ${episode.episode_number}` }} + + + + + +

{{ episode.overview }}

+
+
+
+ No episodes in library
@@ -163,7 +203,9 @@ {{ movie.title }} {{ movie.year || movie.info?.release_date?.slice(0, 4) || "Unknown Year" }} - +
@@ -183,7 +225,10 @@ :visible="episodeReleaseMenu.visible" :x="episodeReleaseMenu.x" :y="episodeReleaseMenu.y" - :episode-name="episodeReleaseMenu.episode?.name || `Episode ${episodeReleaseMenu.episode?.episode_number}`" + :episode-name=" + episodeReleaseMenu.episode?.name || + `Episode ${episodeReleaseMenu.episode?.episode_number}` + " :releases="episodeReleaseMenuReleases" :has-resume-position="props.hasResumePosition" @play="handlePlayVersion" @@ -220,132 +265,172 @@ const emit = defineEmits<{ const seriesRootRef = ref(null) const episodeNavCoords = ref>(new Map()) -const linkedMoviesNavRow = ref(2) -const seasonLayoutStyles = ref< - Map; episodesStyle: Record }> ->(new Map()) +const linkedMoviesNavRow = ref(3) let navLayoutFrame: number | null = null -let seasonLayoutFrame: number | null = null -const EPISODE_TILE_WIDTH_FALLBACK = 200 -const EPISODE_TILE_HEIGHT_FALLBACK = 113 -const EPISODE_GAP_FALLBACK = 6 -const MIN_POSTER_WIDTH = 120 -const MAX_POSTER_WIDTH = 280 -const POSTER_ASPECT_RATIO = 3 / 2 - -function getSeasonPosterStripStyle(seasonIndex: number): Record { - return seasonLayoutStyles.value.get(seasonIndex)?.posterStyle || {} -} - -function getSeasonEpisodesFlowStyle(seasonIndex: number): Record { - return seasonLayoutStyles.value.get(seasonIndex)?.episodesStyle || {} -} - -function parseCssPx(value: string | null | undefined, fallback = 0): number { - const parsed = parseFloat(value || "") - return Number.isFinite(parsed) ? parsed : fallback -} - -function computeSeasonLayoutStyles() { - const root = seriesRootRef.value - if (!root) return - - const nextStyles = new Map< - number, - { posterStyle: Record; episodesStyle: Record } - >() - - for (let seasonIndex = 0; seasonIndex < props.series.seasons.length; seasonIndex += 1) { - const seasonFlow = root.querySelector(`.season-flow[data-season-index="${seasonIndex}"]`) - if (!seasonFlow) continue - - const flowStyle = window.getComputedStyle(seasonFlow) - if (flowStyle.flexDirection.startsWith("column")) { - continue - } - - const episodesFlow = seasonFlow.querySelector(".episodes-flow") - if (!episodesFlow) continue - - const availableWidth = seasonFlow.clientWidth - if (availableWidth <= 0) continue - - const episodesCount = props.series.seasons[seasonIndex]?.episodes?.length || 0 - const sampleTile = episodesFlow.querySelector(".episode-tile") - const tileWidth = sampleTile?.offsetWidth || EPISODE_TILE_WIDTH_FALLBACK - const tileHeight = sampleTile?.offsetHeight || EPISODE_TILE_HEIGHT_FALLBACK - const episodesStyle = window.getComputedStyle(episodesFlow) - const columnGap = parseCssPx(episodesStyle.columnGap, EPISODE_GAP_FALLBACK) - const rowGap = parseCssPx(episodesStyle.rowGap, EPISODE_GAP_FALLBACK) - const paddingLeft = parseCssPx(episodesStyle.paddingLeft) - const paddingRight = parseCssPx(episodesStyle.paddingRight) - const paddingTop = parseCssPx(episodesStyle.paddingTop) - const paddingBottom = parseCssPx(episodesStyle.paddingBottom) - const horizontalPadding = paddingLeft + paddingRight - const verticalPadding = paddingTop + paddingBottom - - const widthForCols = (cols: number) => - cols * tileWidth + Math.max(0, cols - 1) * columnGap + horizontalPadding - - const maxColsWithoutPoster = Math.max( - 1, - Math.floor((availableWidth - horizontalPadding + columnGap) / (tileWidth + columnGap)), - ) - - let targetCols = Math.max(1, Math.min(Math.max(1, episodesCount), maxColsWithoutPoster)) - let episodesWidth = widthForCols(targetCols) - let posterWidth = availableWidth - episodesWidth - - while (targetCols > 1 && posterWidth < MIN_POSTER_WIDTH) { - targetCols -= 1 - episodesWidth = widthForCols(targetCols) - posterWidth = availableWidth - episodesWidth - } - - posterWidth = Math.max(MIN_POSTER_WIDTH, Math.min(MAX_POSTER_WIDTH, posterWidth)) - episodesWidth = Math.max(0, availableWidth - posterWidth) - - const rows = Math.max(1, Math.ceil(Math.max(1, episodesCount) / targetCols)) - const episodesHeight = rows * tileHeight + Math.max(0, rows - 1) * rowGap + verticalPadding - const posterHeight = Math.max(140, Math.min(episodesHeight, posterWidth * POSTER_ASPECT_RATIO)) - - nextStyles.set(seasonIndex, { - posterStyle: { - width: `${posterWidth}px`, - height: `${posterHeight}px`, - flexBasis: `${posterWidth}px`, - minWidth: `${posterWidth}px`, - alignSelf: "flex-start", - }, - episodesStyle: { - width: `${episodesWidth}px`, - flexBasis: `${episodesWidth}px`, - minWidth: `${episodesWidth}px`, - maxWidth: `${episodesWidth}px`, - }, - }) +function getInitialSeasonIndex(): number { + const seasonNumber = props.focusEpisode?.seasonNumber + if (typeof seasonNumber === "number") { + const index = props.series.seasons.findIndex((s) => s.season_number === seasonNumber) + if (index >= 0) return index } - - seasonLayoutStyles.value = nextStyles + return 0 } -function scheduleSeasonLayoutRecompute() { - if (seasonLayoutFrame !== null) return - seasonLayoutFrame = window.requestAnimationFrame(() => { - seasonLayoutFrame = null - computeSeasonLayoutStyles() +const selectedSeasonIndex = ref(getInitialSeasonIndex()) + +const selectedSeason = computed( + () => props.series.seasons[selectedSeasonIndex.value] || null, +) + +function selectSeason(index: number) { + if (index < 0 || index >= props.series.seasons.length) return + if (selectedSeasonIndex.value === index) return + selectedSeasonIndex.value = index + episodeCursorIndex.value = null + scheduleEpisodeMediaReady() + syncSeasonVideoPlayback() + nextTick(() => { scheduleEpisodeNavLayoutRecompute() }) } -function getEpisodeKey(seasonIndex: number, episodeIndex: number): string { - return `${seasonIndex}-${episodeIndex}` +// Episode media settle gating: videos mount with preload="none" and stay +// paused until the jukebox animation has settled, so switching seasons does +// not trigger a burst of video loads mid-transition. The episode still image +// underneath provides the preview picture in the meantime. +const episodeMediaReady = ref(false) +const EPISODE_MEDIA_SETTLE_MS = 600 +let episodeMediaReadyTimer: ReturnType | null = null + +function scheduleEpisodeMediaReady() { + episodeMediaReady.value = false + if (episodeMediaReadyTimer !== null) { + clearTimeout(episodeMediaReadyTimer) + } + episodeMediaReadyTimer = setTimeout(() => { + episodeMediaReadyTimer = null + episodeMediaReady.value = true + syncSeasonVideoPlayback() + }, EPISODE_MEDIA_SETTLE_MS) +} + +// Spoiler avoidance: episodes ahead of the cursor (keyboard/gamepad focus, or +// mouse hover via the focus it triggers) are dimmed with their synopsis hidden. +const episodeCursorIndex = ref(null) + +function isEpisodeAhead(episodeIndex: number): boolean { + return episodeCursorIndex.value !== null && episodeIndex > episodeCursorIndex.value +} + +function handleEpisodeFocusIn(event: FocusEvent, episodeIndex: number) { + episodeCursorIndex.value = episodeIndex + // Updating the cursor re-renders this tile's :class binding, and Vue's class + // patch rewrites the whole class attribute, clobbering the "nav-focused" + // class that the keyboard-navigation composable adds imperatively during + // this same focusin dispatch. Re-add it once Vue has settled. + const el = event.currentTarget as HTMLElement | null + nextTick(() => { + if (el && el === document.activeElement) { + el.classList.add("nav-focused") + } + }) +} + +function getEpisodeStill(episode: Episode): string | undefined { + if (episode.still_path) { + return getCoverUrl(episode.still_path, props.series.root_id) + } + return undefined +} + +function handleVideoPlaying(event: Event) { + ;(event.target as HTMLVideoElement | null)?.classList.add("is-playing") +} + +// Jukebox stage: cards are placed at constant angular steps on a semicircular +// arc of radius R around the selection — x = R·sin(θ), depth = R·(1−cos θ) — +// so the browser's perspective does a true 3D ring. Every season stays fully +// opaque and visible; extreme cards pile up nearly edge-on at the arc cap but +// are never hidden. The selected card is shifted left of center, leaving the +// right side free for the floating season-info panel. +const seasonStageRef = ref(null) +const stageWidth = ref(1280) + +function measureStageWidth() { + stageWidth.value = seasonStageRef.value?.clientWidth || window.innerWidth +} + +// Keep in sync with .season-stage { perspective } in the styles. +const STAGE_PERSPECTIVE_PX = 1200 + +const stageMetrics = computed(() => { + const width = stageWidth.value + if (width <= 600) { + // Info panel floats below the poster here, so the ring stays centered. + return { posterWidth: 170, radius: 900, stepDeg: 16, rightStartDeg: 16, infoShift: 0, maxDeg: 80 } + } + if (width <= 900) { + return { posterWidth: 230, radius: 1100, stepDeg: 14, rightStartDeg: 28, infoShift: 160, maxDeg: 80 } + } + return { posterWidth: 290, radius: 1300, stepDeg: 13, rightStartDeg: 32, infoShift: 200, maxDeg: 80 } +}) + +function getSeasonCardStyle(index: number): Record { + const offset = index - selectedSeasonIndex.value + const absOffset = Math.abs(offset) + const metrics = stageMetrics.value + + // Angular position on the arc. The right side starts past the opening + // reserved for the season-info panel; the cap keeps extreme cards from + // turning fully edge-on. + let thetaDeg: number + if (offset === 0) { + thetaDeg = 0 + } else if (offset > 0) { + thetaDeg = Math.min(metrics.rightStartDeg + (absOffset - 1) * metrics.stepDeg, metrics.maxDeg) + } else { + thetaDeg = -Math.min(metrics.stepDeg * absOffset, metrics.maxDeg) + } + + const theta = (thetaDeg * Math.PI) / 180 + const depth = metrics.radius * (1 - Math.cos(theta)) + 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 { + transform: `translateX(-50%) translateX(${translateX}px) translateZ(${-depth}px) rotateY(${-thetaDeg}deg)`, + zIndex: String(100 - absOffset), + } +} + +function handleStageResize() { + measureStageWidth() + scheduleEpisodeNavLayoutRecompute() +} + +function focusFirstEpisode() { + const firstTile = seriesRootRef.value?.querySelector(".episode-tile") + firstTile?.focus() +} + +const EPISODE_NAV_FIRST_ROW = 3 + +function getEpisodeKey(episodeIndex: number): string { + return `${episodeIndex}` } function getEpisodeNavAttrs(seasonIndex: number, episodeIndex: number) { - const key = getEpisodeKey(seasonIndex, episodeIndex) - const coords = episodeNavCoords.value.get(key) || { row: seasonIndex + 2, col: episodeIndex } + const key = getEpisodeKey(episodeIndex) + const coords = episodeNavCoords.value.get(key) || { + row: EPISODE_NAV_FIRST_ROW, + col: episodeIndex, + } return { ...navAttrs(coords.row, coords.col), "data-season-index": seasonIndex, @@ -358,31 +443,23 @@ function recomputeEpisodeNavLayout() { if (!root) return const nextCoords = new Map() - let currentRow = 2 + let currentRow = EPISODE_NAV_FIRST_ROW - for (let seasonIndex = 0; seasonIndex < props.series.seasons.length; seasonIndex += 1) { - const tiles = Array.from( - root.querySelectorAll(`.episode-tile[data-season-index="${seasonIndex}"]`), - ).sort((a, b) => { - const aIndex = parseInt(a.getAttribute("data-episode-index") || "0", 10) - const bIndex = parseInt(b.getAttribute("data-episode-index") || "0", 10) - return aIndex - bIndex - }) + const tiles = Array.from(root.querySelectorAll(".episode-tile")).sort((a, b) => { + const aIndex = parseInt(a.getAttribute("data-episode-index") || "0", 10) + const bIndex = parseInt(b.getAttribute("data-episode-index") || "0", 10) + return aIndex - bIndex + }) - if (tiles.length === 0) { - const fallbackCount = props.series.seasons[seasonIndex]?.episodes?.length || 0 - for (let episodeIndex = 0; episodeIndex < fallbackCount; episodeIndex += 1) { - nextCoords.set(getEpisodeKey(seasonIndex, episodeIndex), { - row: currentRow, - col: episodeIndex, - }) - } - if (fallbackCount > 0) { - currentRow += 1 - } - continue + if (tiles.length === 0) { + const fallbackCount = selectedSeason.value?.episodes?.length || 0 + for (let episodeIndex = 0; episodeIndex < fallbackCount; episodeIndex += 1) { + nextCoords.set(getEpisodeKey(episodeIndex), { row: currentRow, col: episodeIndex }) } - + if (fallbackCount > 0) { + currentRow += 1 + } + } else { let lastTop: number | null = null let rowOffset = -1 let colInRow = 0 @@ -398,7 +475,7 @@ function recomputeEpisodeNavLayout() { colInRow = 0 } - nextCoords.set(getEpisodeKey(seasonIndex, episodeIndex), { + nextCoords.set(getEpisodeKey(episodeIndex), { row: currentRow + rowOffset, col: colInRow, }) @@ -426,7 +503,6 @@ watch( () => props.series.seasons.map((season) => season.episodes.length), () => { nextTick(() => { - scheduleSeasonLayoutRecompute() scheduleEpisodeNavLayoutRecompute() }) }, @@ -437,28 +513,27 @@ watch( watch( () => props.focusEpisode, (ep) => { - if (ep) { - // Delay to ensure DOM is fully rendered after route transition + if (!ep) return + const seasonIndex = + props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1 + if (seasonIndex < 0) return + selectedSeasonIndex.value = seasonIndex + // Delay to ensure DOM is fully rendered after season switch / route transition + nextTick(() => { setTimeout(() => { - // Find the season index and episode index - const seasonIndex = - props.series.seasons?.findIndex((s) => s.season_number === ep.seasonNumber) ?? -1 - if (seasonIndex >= 0) { - const episodeIndex = - props.series.seasons?.[seasonIndex]?.episodes?.findIndex( - (e) => e.episode_number === ep.episodeNumber, - ) ?? -1 - if (episodeIndex >= 0) { - const selector = `.episode-tile[data-season-index="${seasonIndex}"][data-episode-index="${episodeIndex}"]` - const element = seriesRootRef.value?.querySelector(selector) as HTMLElement | null - if (element) { - element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }) - element.focus() - } - } + const episodeIndex = + props.series.seasons?.[seasonIndex]?.episodes?.findIndex( + (e) => e.episode_number === ep.episodeNumber, + ) ?? -1 + if (episodeIndex < 0) return + const selector = `.episode-tile[data-season-index="${seasonIndex}"][data-episode-index="${episodeIndex}"]` + const element = seriesRootRef.value?.querySelector(selector) as HTMLElement | null + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }) + element.focus() } }, 150) - } + }) }, { immediate: true }, ) @@ -631,17 +706,18 @@ function handlePlayVersion(filePath: string | null) { function handleOpenFolderFromMenu(filePath: string) { if (!filePath) return const torrent = episodeReleaseMenu.value.episode?.files - ? Object.values(episodeReleaseMenu.value.episode.files).find((t) => t.playable_file === filePath) + ? Object.values(episodeReleaseMenu.value.episode.files).find( + (t) => t.playable_file === filePath, + ) : undefined const rootId = torrent?.root_id ?? props.series.root_id ?? null emit("openFolder", filePath, rootId) closeEpisodeReleaseMenu() } -// Video refs for hover effects +// Video refs for hover effects (key = episode index; only one season is mounted) const videoRefs = ref>(new Map()) const safariAutoplay = isSafariBrowser() -const activeSeasonIndex = ref(0) const SEASON_VIDEO_STARTUP_STEP_MS = 500 const seasonStartupTimers = new Map>() let seasonStartupToken = 0 @@ -657,14 +733,9 @@ const { stopped: previewPlaybackStopped } = useIdlePreviewPlayback({ onRestart: () => syncSeasonVideoPlayback(), }) -function parseEpisodeKey(key: string): { seasonIndex: number; episodeIndex: number } | null { - const [seasonPart, episodePart] = key.split("-") - const seasonIndex = parseInt(seasonPart || "", 10) - const episodeIndex = parseInt(episodePart || "", 10) - if (!Number.isFinite(seasonIndex) || !Number.isFinite(episodeIndex)) { - return null - } - return { seasonIndex, episodeIndex } +function parseEpisodeIndex(key: string): number | null { + const episodeIndex = parseInt(key, 10) + return Number.isFinite(episodeIndex) ? episodeIndex : null } function clearSeasonStartupTimers() { @@ -731,6 +802,7 @@ function stopEpisodePreviews() { function pauseEpisodeVideo(video: HTMLVideoElement) { video.pause() + video.classList.remove("is-playing") if (video.readyState >= 1) { video.currentTime = 0 } @@ -751,22 +823,22 @@ function syncSeasonVideoPlayback(priorityKey?: string) { const videosToStop: Array<{ key: string; episodeIndex: number; video: HTMLVideoElement }> = [] for (const [key, video] of videoRefs.value.entries()) { - const parsed = parseEpisodeKey(key) - if (!parsed) { + const episodeIndex = parseEpisodeIndex(key) + if (episodeIndex === null) { pauseEpisodeVideo(video) continue } - // Only the browsed season's on-screen tiles may play, and only while the - // user is active. + // Only on-screen tiles may play, and only while the user is active. const eligible = - parsed.seasonIndex === activeSeasonIndex.value && + episodeMediaReady.value && + !isEpisodeAhead(episodeIndex) && !offscreenEpisodeKeys.has(key) && !previewPlaybackStopped.value if (!eligible) { if (!video.paused && !video.ended) { - videosToStop.push({ key, episodeIndex: parsed.episodeIndex, video }) + videosToStop.push({ key, episodeIndex, video }) } else { pauseEpisodeVideo(video) } @@ -782,7 +854,7 @@ function syncSeasonVideoPlayback(priorityKey?: string) { pauseEpisodeVideo(video) videosToStart.push({ key, - episodeIndex: parsed.episodeIndex, + episodeIndex, video, }) } @@ -797,10 +869,13 @@ function syncSeasonVideoPlayback(priorityKey?: string) { for (let i = 0; i < videosToStart.length; i += 1) { const { key, video } = videosToStart[i] - const delayMs = priorityKey ? (i === 0 ? 0 : i * SEASON_VIDEO_STARTUP_STEP_MS) : i * SEASON_VIDEO_STARTUP_STEP_MS + const delayMs = priorityKey + ? i === 0 + ? 0 + : i * SEASON_VIDEO_STARTUP_STEP_MS + : i * SEASON_VIDEO_STARTUP_STEP_MS const timeoutId = setTimeout(() => { - if (token !== seasonStartupToken || activeSeasonIndex.value < 0 || previewPlaybackStopped.value) - return + if (token !== seasonStartupToken || previewPlaybackStopped.value) return if (safariAutoplay && video.readyState >= 1) { video.currentTime = 0.001 } @@ -823,17 +898,6 @@ function syncSeasonVideoPlayback(priorityKey?: string) { } } -function setActiveSeason(seasonIndex: number) { - if (seasonIndex < 0) return - if (activeSeasonIndex.value === seasonIndex) return - activeSeasonIndex.value = seasonIndex - syncSeasonVideoPlayback() -} - -function handleEpisodeFocus(seasonIndex: number) { - setActiveSeason(seasonIndex) -} - function cleanupVideo(video: HTMLVideoElement | null | undefined) { if (!video) return video.pause() @@ -841,7 +905,7 @@ function cleanupVideo(video: HTMLVideoElement | null | undefined) { video.load() } -// Track mounted videos and sync playback with active season. +// Track mounted videos and sync playback. function setVideoRef(el: HTMLVideoElement | null, key: string) { if (el) { const existing = videoRefs.value.get(key) @@ -858,16 +922,6 @@ function setVideoRef(el: HTMLVideoElement | null, key: string) { // the element's ancestors are attached. el.dataset.previewKey = key getEpisodeVisibilityObserver().observe(el) - el.addEventListener( - "loadeddata", - () => { - const parsed = parseEpisodeKey(key) - if (!parsed || parsed.seasonIndex !== activeSeasonIndex.value) { - pauseEpisodeVideo(el) - } - }, - { once: true }, - ) syncSeasonVideoPlayback() } else { const timeoutId = seasonStartupTimers.get(key) @@ -936,7 +990,10 @@ function rampEpisodeVolume(key: string, targetVolume: number) { volumeFadeIntervals.set(key, fadeInterval) } -function scheduleEpisodeHoverAudioIdleFade(key: string, delayMs: number = AUDIO_IDLE_FADE_DELAY_MS) { +function scheduleEpisodeHoverAudioIdleFade( + key: string, + delayMs: number = AUDIO_IDLE_FADE_DELAY_MS, +) { clearEpisodeHoverAudioIdleTimer() hoverEpisodeAudioIdleTimer = setTimeout(() => { rampEpisodeVolume(key, 0) @@ -953,29 +1010,26 @@ function handleEpisodeHoverAudioMouseMove() { } // Handle hover-based audio fade in/out for episode videos -function handleEpisodeHover(eventOrKey: MouseEvent | string, keyOrIsEntering: string | boolean, maybeIsEntering?: boolean) { +function handleEpisodeHover( + eventOrKey: MouseEvent | string, + keyOrIsEntering: string | boolean, + maybeIsEntering?: boolean, +) { const event = typeof eventOrKey === "string" ? null : eventOrKey const key = typeof eventOrKey === "string" ? eventOrKey : (keyOrIsEntering as string) - const isEntering = typeof eventOrKey === "string" ? Boolean(keyOrIsEntering) : Boolean(maybeIsEntering) + const isEntering = + typeof eventOrKey === "string" ? Boolean(keyOrIsEntering) : Boolean(maybeIsEntering) if (!document.documentElement.classList.contains("mouse-active")) return const video = videoRefs.value.get(key) if (!video) return - const parsed = parseEpisodeKey(key) - if (!parsed) return if (isEntering) { if (event?.currentTarget instanceof HTMLElement) { event.currentTarget.focus({ preventScroll: true }) } - setActiveSeason(parsed.seasonIndex) syncSeasonVideoPlayback(key) - } - - if (parsed.seasonIndex !== activeSeasonIndex.value) return - - if (isEntering) { hoveredEpisodeAudioKey = key videoRefs.value.forEach((_, k) => { if (k !== key) { @@ -1073,10 +1127,22 @@ function getCollageSliceStyle(season: Season, index: number) { } } -// Truncate text -function truncate(text: string, maxLength: number): string { - if (text.length <= maxLength) return text - return text.slice(0, maxLength).trim() + "..." +function formatDate(value: string | null | undefined): string | null { + if (!value) return null + const parsedDate = new Date(value) + if (Number.isNaN(parsedDate.getTime())) return value + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(parsedDate) +} + +function formatRuntime(minutes: number): string { + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + if (hours === 0) return `${mins}m` + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h` } // Handle play @@ -1097,38 +1163,28 @@ function handleEpisodeEnter(event: KeyboardEvent, episode: Episode) { onMounted(() => { window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) - window.addEventListener("resize", scheduleEpisodeNavLayoutRecompute, { passive: true }) - window.addEventListener("resize", scheduleSeasonLayoutRecompute, { passive: true }) + window.addEventListener("resize", handleStageResize, { passive: true }) window.addEventListener("mousemove", handleEpisodeHoverAudioMouseMove, { passive: true }) nextTick(() => { - const focusSeasonNumber = props.focusEpisode?.seasonNumber - if (typeof focusSeasonNumber === "number") { - const focusSeasonIndex = - props.series.seasons.findIndex((season) => season.season_number === focusSeasonNumber) ?? -1 - if (focusSeasonIndex >= 0) { - activeSeasonIndex.value = focusSeasonIndex - } - } - syncSeasonVideoPlayback() - scheduleSeasonLayoutRecompute() + measureStageWidth() + scheduleEpisodeMediaReady() scheduleEpisodeNavLayoutRecompute() }) }) onUnmounted(() => { window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) - window.removeEventListener("resize", scheduleEpisodeNavLayoutRecompute) - window.removeEventListener("resize", scheduleSeasonLayoutRecompute) + window.removeEventListener("resize", handleStageResize) window.removeEventListener("mousemove", handleEpisodeHoverAudioMouseMove) clearEpisodeHoverAudioIdleTimer() clearSeasonStartupTimers() + if (episodeMediaReadyTimer !== null) { + clearTimeout(episodeMediaReadyTimer) + episodeMediaReadyTimer = null + } episodeVisibilityObserver?.disconnect() episodeVisibilityObserver = null offscreenEpisodeKeys.clear() - if (seasonLayoutFrame !== null) { - window.cancelAnimationFrame(seasonLayoutFrame) - seasonLayoutFrame = null - } if (navLayoutFrame !== null) { window.cancelAnimationFrame(navLayoutFrame) navLayoutFrame = null @@ -1146,30 +1202,19 @@ onUnmounted(() => { }) watch( - () => props.focusEpisode, - (episode) => { - if (!episode) return - const seasonIndex = props.series.seasons.findIndex( - (season) => season.season_number === episode.seasonNumber, - ) - if (seasonIndex >= 0) { - setActiveSeason(seasonIndex) + () => props.series.seasons.length, + () => { + if (selectedSeasonIndex.value >= props.series.seasons.length) { + selectedSeasonIndex.value = Math.max(0, props.series.seasons.length - 1) } + syncSeasonVideoPlayback() }, ) -watch( - () => props.series.seasons.map((season) => season.episodes.length), - () => { - if (activeSeasonIndex.value >= props.series.seasons.length) { - activeSeasonIndex.value = Math.max(0, props.series.seasons.length - 1) - } - syncSeasonVideoPlayback() - nextTick(() => { - scheduleSeasonLayoutRecompute() - }) - }, -) +// Episodes ahead of the cursor are spoiler-faded; stop their playback too. +watch(episodeCursorIndex, () => { + syncSeasonVideoPlayback() +})