Implement JS-driven season poster sizing from episode grid remainder
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user