Implement JS-driven season poster sizing from episode grid remainder

This commit is contained in:
2026-05-30 04:19:14 +00:00
parent 613b516785
commit 7675940029
+136 -4
View File
@@ -47,10 +47,15 @@
v-for="(season, sIndex) in series.seasons" v-for="(season, sIndex) in series.seasons"
:key="sIndex" :key="sIndex"
class="season-flow" class="season-flow"
:data-season-index="sIndex"
:class="{ 'season-even': sIndex % 2 === 1 }" :class="{ 'season-even': sIndex % 2 === 1 }"
> >
<!-- Season poster - tall strip on the side --> <!-- Season poster - tall strip on the side -->
<div class="season-poster-strip" :class="{ 'strip-right': sIndex % 2 === 1 }"> <div
class="season-poster-strip"
:class="{ 'strip-right': sIndex % 2 === 1 }"
:style="getSeasonPosterStripStyle(sIndex)"
>
<div class="poster-container"> <div class="poster-container">
<img <img
v-if="getSeasonPoster(season)" v-if="getSeasonPoster(season)"
@@ -71,7 +76,7 @@
</div> </div>
<!-- Episodes flowing grid --> <!-- Episodes flowing grid -->
<div class="episodes-flow"> <div class="episodes-flow" :style="getSeasonEpisodesFlowStyle(sIndex)">
<div <div
v-for="(episode, eIndex) in season.episodes" v-for="(episode, eIndex) in season.episodes"
:key="`${sIndex}-${episode.episode_number}`" :key="`${sIndex}-${episode.episode_number}`"
@@ -239,7 +244,123 @@ const emit = defineEmits<{
const seriesRootRef = ref<HTMLElement | null>(null) const seriesRootRef = ref<HTMLElement | null>(null)
const episodeNavCoords = ref<Map<string, { row: number; col: number }>>(new Map()) const episodeNavCoords = ref<Map<string, { row: number; col: number }>>(new Map())
const linkedMoviesNavRow = ref(2) const linkedMoviesNavRow = ref(2)
const seasonLayoutStyles = ref<
Map<number, { posterStyle: Record<string, string>; episodesStyle: Record<string, string> }>
>(new Map())
let navLayoutFrame: number | null = null 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<string, string> {
return seasonLayoutStyles.value.get(seasonIndex)?.posterStyle || {}
}
function getSeasonEpisodesFlowStyle(seasonIndex: number): Record<string, string> {
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<string, string>; episodesStyle: Record<string, string> }
>()
for (let seasonIndex = 0; seasonIndex < props.series.seasons.length; seasonIndex += 1) {
const seasonFlow = root.querySelector<HTMLElement>(`.season-flow[data-season-index="${seasonIndex}"]`)
if (!seasonFlow) continue
const flowStyle = window.getComputedStyle(seasonFlow)
if (flowStyle.flexDirection.startsWith("column")) {
continue
}
const episodesFlow = seasonFlow.querySelector<HTMLElement>(".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<HTMLElement>(".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 { function getEpisodeKey(seasonIndex: number, episodeIndex: number): string {
return `${seasonIndex}-${episodeIndex}` return `${seasonIndex}-${episodeIndex}`
@@ -328,6 +449,7 @@ watch(
() => props.series.seasons.map((season) => season.episodes.length), () => props.series.seasons.map((season) => season.episodes.length),
() => { () => {
nextTick(() => { nextTick(() => {
scheduleSeasonLayoutRecompute()
scheduleEpisodeNavLayoutRecompute() scheduleEpisodeNavLayoutRecompute()
}) })
}, },
@@ -942,6 +1064,7 @@ function handleEpisodeEnter(event: KeyboardEvent, episode: Episode) {
onMounted(() => { onMounted(() => {
window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) window.addEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.addEventListener("resize", scheduleEpisodeNavLayoutRecompute, { passive: true }) window.addEventListener("resize", scheduleEpisodeNavLayoutRecompute, { passive: true })
window.addEventListener("resize", scheduleSeasonLayoutRecompute, { passive: true })
nextTick(() => { nextTick(() => {
const focusSeasonNumber = props.focusEpisode?.seasonNumber const focusSeasonNumber = props.focusEpisode?.seasonNumber
if (typeof focusSeasonNumber === "number") { if (typeof focusSeasonNumber === "number") {
@@ -952,6 +1075,7 @@ onMounted(() => {
} }
} }
syncSeasonVideoPlayback() syncSeasonVideoPlayback()
scheduleSeasonLayoutRecompute()
scheduleEpisodeNavLayoutRecompute() scheduleEpisodeNavLayoutRecompute()
}) })
}) })
@@ -959,7 +1083,12 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener) window.removeEventListener("mediahive:gamepad-action", handleGamepadAction as EventListener)
window.removeEventListener("resize", scheduleEpisodeNavLayoutRecompute) window.removeEventListener("resize", scheduleEpisodeNavLayoutRecompute)
window.removeEventListener("resize", scheduleSeasonLayoutRecompute)
clearSeasonStartupTimers() clearSeasonStartupTimers()
if (seasonLayoutFrame !== null) {
window.cancelAnimationFrame(seasonLayoutFrame)
seasonLayoutFrame = null
}
if (navLayoutFrame !== null) { if (navLayoutFrame !== null) {
window.cancelAnimationFrame(navLayoutFrame) window.cancelAnimationFrame(navLayoutFrame)
navLayoutFrame = null navLayoutFrame = null
@@ -996,6 +1125,9 @@ watch(
activeSeasonIndex.value = Math.max(0, props.series.seasons.length - 1) activeSeasonIndex.value = Math.max(0, props.series.seasons.length - 1)
} }
syncSeasonVideoPlayback() syncSeasonVideoPlayback()
nextTick(() => {
scheduleSeasonLayoutRecompute()
})
}, },
) )
</script> </script>
@@ -1213,14 +1345,14 @@ watch(
width: 280px; width: 280px;
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
align-self: stretch; align-self: flex-start;
} }
.poster-container { .poster-container {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 200px; min-height: 0;
} }
.season-poster-img { .season-poster-img {