From 7675940029a993fac281dc5d9578f7d1d63b1396 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 30 May 2026 04:19:14 +0000 Subject: [PATCH] Implement JS-driven season poster sizing from episode grid remainder --- frontend/src/components/SeriesFullView.vue | 140 ++++++++++++++++++++- 1 file changed, 136 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/SeriesFullView.vue b/frontend/src/components/SeriesFullView.vue index faf4ff6..ccbe978 100644 --- a/frontend/src/components/SeriesFullView.vue +++ b/frontend/src/components/SeriesFullView.vue @@ -47,10 +47,15 @@ v-for="(season, sIndex) in series.seasons" :key="sIndex" class="season-flow" + :data-season-index="sIndex" :class="{ 'season-even': sIndex % 2 === 1 }" > -
+
-
+
(null) const episodeNavCoords = ref>(new Map()) const linkedMoviesNavRow = ref(2) +const seasonLayoutStyles = ref< + Map; episodesStyle: Record }> +>(new Map()) 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`, + }, + }) + } + + seasonLayoutStyles.value = nextStyles +} + +function scheduleSeasonLayoutRecompute() { + if (seasonLayoutFrame !== null) return + seasonLayoutFrame = window.requestAnimationFrame(() => { + seasonLayoutFrame = null + computeSeasonLayoutStyles() + scheduleEpisodeNavLayoutRecompute() + }) +} function getEpisodeKey(seasonIndex: number, episodeIndex: number): string { return `${seasonIndex}-${episodeIndex}` @@ -328,6 +449,7 @@ watch( () => props.series.seasons.map((season) => season.episodes.length), () => { nextTick(() => { + scheduleSeasonLayoutRecompute() scheduleEpisodeNavLayoutRecompute() }) }, @@ -942,6 +1064,7 @@ 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 }) nextTick(() => { const focusSeasonNumber = props.focusEpisode?.seasonNumber if (typeof focusSeasonNumber === "number") { @@ -952,6 +1075,7 @@ onMounted(() => { } } syncSeasonVideoPlayback() + scheduleSeasonLayoutRecompute() scheduleEpisodeNavLayoutRecompute() }) }) @@ -959,7 +1083,12 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) window.removeEventListener("resize", scheduleEpisodeNavLayoutRecompute) + window.removeEventListener("resize", scheduleSeasonLayoutRecompute) clearSeasonStartupTimers() + if (seasonLayoutFrame !== null) { + window.cancelAnimationFrame(seasonLayoutFrame) + seasonLayoutFrame = null + } if (navLayoutFrame !== null) { window.cancelAnimationFrame(navLayoutFrame) navLayoutFrame = null @@ -996,6 +1125,9 @@ watch( activeSeasonIndex.value = Math.max(0, props.series.seasons.length - 1) } syncSeasonVideoPlayback() + nextTick(() => { + scheduleSeasonLayoutRecompute() + }) }, ) @@ -1213,14 +1345,14 @@ watch( width: 280px; flex-shrink: 0; position: relative; - align-self: stretch; + align-self: flex-start; } .poster-container { position: relative; width: 100%; height: 100%; - min-height: 200px; + min-height: 0; } .season-poster-img {