Fix & refactor some image aspect ratios (#222)

Fix image aspect ratios for episode cards that differ from the video's
aspect ratio.

Also use the video's aspect ratio for chapter cards instead of assuming
16x9.

Dev: Create constants for the main ratios used

Fixes #215
This commit is contained in:
damontecres 2025-11-14 16:13:18 -05:00 committed by GitHub
parent 6a912c0af3
commit 72fb732e63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 72 additions and 42 deletions

View file

@ -74,6 +74,12 @@ object Cards {
val playedPercentHeight = 6.dp
}
object AspectRatios {
const val WIDE = 16f / 9f
const val FOUR_THREE = 4f / 3f
const val TALL = 2f / 3f
}
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,

View file

@ -34,6 +34,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -53,7 +54,7 @@ fun BannerCard(
favorite: Boolean = false,
playPercent: Double = 0.0,
cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource? = null,
) {
var imageError by remember { mutableStateOf(false) }

View file

@ -30,9 +30,9 @@ fun ChapterCard(
position: Duration,
imageUrl: String?,
onClick: () -> Unit,
aspectRatio: Float,
modifier: Modifier = Modifier,
cardHeight: Dp = 140.dp * .85f,
aspectRatio: Float = 16f / 9,
onLongClick: (() -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
) {

View file

@ -21,6 +21,7 @@ import com.github.damontecres.wholphin.data.model.Chapter
@Composable
fun ChapterRow(
chapters: List<Chapter>,
aspectRatio: Float,
onClick: (Chapter) -> Unit,
modifier: Modifier = Modifier,
onLongClick: ((Chapter) -> Unit)? = null,
@ -50,6 +51,7 @@ fun ChapterRow(
position = item.position,
imageUrl = item.imageUrl,
onClick = { onClick(item) },
aspectRatio = aspectRatio,
modifier = Modifier,
onLongClick = onLongClick?.let { { it(item) } },
)

View file

@ -30,6 +30,7 @@ import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.seasonEpisode
import kotlinx.coroutines.delay
@ -63,7 +64,7 @@ fun EpisodeCard(
} else {
focusedAfterDelay = false
}
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
Column(

View file

@ -25,6 +25,7 @@ import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
@ -38,7 +39,7 @@ fun GridCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageAspectRatio: Float = 2f / 3f,
imageAspectRatio: Float = AspectRatios.TALL,
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.util.FocusPair
@Composable
@ -112,7 +113,8 @@ fun BannerItemRow(
name = title,
imageUrl = item?.imageUrl,
aspectRatio =
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,
cornerText = item?.data?.indexNumber?.let { "E$it" },
played = item?.data?.userData?.played ?: false,
playPercent = item?.data?.userData?.playedPercentage ?: 0.0,

View file

@ -22,6 +22,7 @@ import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
@ -81,7 +82,7 @@ fun PersonCard(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f), // TODO,
.aspectRatio(AspectRatios.TALL), // TODO,
)
}
Column(

View file

@ -26,6 +26,7 @@ import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.enableMarquee
import kotlinx.coroutines.delay
@ -42,7 +43,7 @@ fun SeasonCard(
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f),
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: AspectRatios.TALL,
) = SeasonCard(
title = item?.title,
subtitle = item?.subtitle,
@ -82,7 +83,7 @@ fun SeasonCard(
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = (2f / 3f),
aspectRatio: Float = AspectRatios.TALL,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
@ -102,7 +103,6 @@ fun SeasonCard(
} else {
focusedAfterDelay = false
}
// val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
Column(

View file

@ -51,9 +51,11 @@ import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -486,6 +488,7 @@ fun MovieDetailsContent(
item {
ChapterRow(
chapters = chapters,
aspectRatio = movie.data.aspectRatioFloat ?: AspectRatios.WIDE,
onClick = {
position = CHAPTER_ROW
playOnClick.invoke(it.position)

View file

@ -42,8 +42,8 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -216,10 +216,8 @@ fun SeriesOverviewContent(
?.data
?.primaryImageAspectRatio
?.toFloat()
?.coerceAtLeast(
episode.data.aspectRatioFloat ?: (4f / 3f),
)
?: (16f / 9), // TODO some episode images don't match the file's aspect ratio
?.coerceAtLeast(AspectRatios.FOUR_THREE)
?: (AspectRatios.WIDE),
cornerText = cornerText,
played = episode?.data?.userData?.played ?: false,
playPercent =

View file

@ -46,6 +46,7 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -353,11 +354,10 @@ fun HomePageContent(
.fillMaxWidth()
.animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick ->
// TODO better aspect ration handling?
BannerCard(
name = item?.data?.seriesName ?: item?.name,
imageUrl = item?.imageUrl,
aspectRatio = (2f / 3f),
aspectRatio = AspectRatios.TALL,
cornerText =
item?.data?.indexNumber?.let { "E$it" }
?: item?.data?.childCount?.let { if (it > 0) it.toString() else null },

View file

@ -45,6 +45,7 @@ import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -62,7 +63,7 @@ fun NextUpEpisode(
onClick: () -> Unit,
timeLeft: Duration?,
modifier: Modifier = Modifier,
aspectRatio: Float = 16f / 9,
aspectRatio: Float = AspectRatios.WIDE,
) {
val interactionSource = remember { MutableInteractionSource() }
val focusRequester = remember { FocusRequester() }
@ -202,7 +203,7 @@ private fun NextUpEpisodePreview() {
"But it just keeps going and going.",
imageUrl = "",
onClick = {},
aspectRatio = 4f / 3,
aspectRatio = AspectRatios.FOUR_THREE,
timeLeft = 30.seconds,
modifier =
Modifier

View file

@ -50,7 +50,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -75,6 +77,7 @@ private val subtitleTextSize = 18.sp
*/
@Composable
fun PlaybackOverlay(
item: BaseItem?,
title: String?,
subtitleStreams: List<SubtitleStream>,
chapters: List<Chapter>,
@ -236,6 +239,7 @@ fun PlaybackOverlay(
name = chapter.name,
position = chapter.position,
imageUrl = chapter.imageUrl,
aspectRatio = item?.data?.aspectRatioFloat ?: AspectRatios.WIDE,
onClick = {
playerControls.seekTo(chapter.position.inWholeMilliseconds)
controllerViewState.hideControls()

View file

@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
@ -320,6 +321,7 @@ fun PlaybackPage(
.padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxSize()
.background(Color.Transparent),
item = currentPlayback?.item,
title = title,
subtitle = subtitle,
subtitleStreams = subtitleStreams,
@ -476,7 +478,9 @@ fun PlaybackPage(
).joinToString(" - "),
description = it.data.overview,
imageUrl = it.imageUrl,
aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
aspectRatio =
it.data.primaryImageAspectRatio?.toFloat()
?: AspectRatios.WIDE,
onClick = {
viewModel.reportInteraction()
viewModel.playUpNextUp()

View file

@ -73,7 +73,6 @@ import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.api.sockets.subscribe
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSegmentDto
@ -138,7 +137,7 @@ class PlaybackViewModel
private lateinit var preferences: UserPreferences
private lateinit var deviceProfile: DeviceProfile
private lateinit var itemId: UUID
private lateinit var dto: BaseItemDto
private lateinit var item: BaseItem
private var activityListener: TrackActivityPlaybackListener? = null
val nextUp = MutableLiveData<BaseItem?>()
@ -200,10 +199,11 @@ class PlaybackViewModel
} else {
queriedItem
}
val item = BaseItem.from(base, api)
val played =
play(
base,
item,
destination.positionMs,
destination.itemPlayback,
destination.forceTranscoding,
@ -224,25 +224,25 @@ class PlaybackViewModel
}
private suspend fun play(
base: BaseItemDto,
item: BaseItem,
positionMs: Long,
itemPlayback: ItemPlayback? = null,
forceTranscoding: Boolean = false,
): Boolean =
withContext(Dispatchers.IO) {
Timber.i("Playing ${base.id}")
Timber.i("Playing ${item.id}")
autoSkippedSegments.clear()
if (base.type !in supportItemKinds) {
if (item.type !in supportItemKinds) {
showToast(
context,
"Unsupported type '${base.type}', skipping...",
"Unsupported type '${item.type}', skipping...",
Toast.LENGTH_SHORT,
)
return@withContext false
}
val isLiveTv = base.type == BaseItemKind.TV_CHANNEL
val isLiveTv = item.type == BaseItemKind.TV_CHANNEL
dto = base
val base = item.data
val title =
if (base.type == BaseItemKind.EPISODE) {
base.seriesName
@ -354,7 +354,7 @@ class PlaybackViewModel
this@PlaybackViewModel.subtitleStreams.value = subtitleStreams
changeStreams(
base,
item,
itemPlaybackToUse,
audioIndex,
subtitleIndex,
@ -374,7 +374,7 @@ class PlaybackViewModel
@OptIn(UnstableApi::class)
private suspend fun changeStreams(
item: BaseItemDto,
item: BaseItem,
currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!,
audioIndex: Int?,
subtitleIndex: Int?,
@ -495,7 +495,8 @@ class PlaybackViewModel
val playback =
CurrentPlayback(
listOf(),
item = item,
tracks = listOf(),
playMethod = transcodeType,
playSessionId = response.playSessionId,
liveStreamId = source.liveStreamId,
@ -563,7 +564,7 @@ class PlaybackViewModel
}
}
val trickPlayInfo =
item.trickplay
item.data.trickplay
?.get(source.id)
?.values
?.firstOrNull()
@ -577,7 +578,7 @@ class PlaybackViewModel
fun changeAudioStream(index: Int) {
viewModelScope.launchIO {
changeStreams(
dto,
item,
currentItemPlayback.value!!,
index,
currentItemPlayback.value?.subtitleIndex,
@ -590,7 +591,7 @@ class PlaybackViewModel
fun changeSubtitleStream(index: Int?): Job =
viewModelScope.launchIO {
changeStreams(
dto,
item,
currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex,
index,
@ -600,7 +601,7 @@ class PlaybackViewModel
}
fun getTrickplayUrl(index: Int): String? {
val itemId = dto.id
val itemId = item.id
val mediaSourceId = currentItemPlayback.value?.sourceId
val trickPlayInfo = trickplay.value ?: return null
return api.trickplayApi.getTrickplayTileImageUrl(
@ -732,7 +733,7 @@ class PlaybackViewModel
viewModelScope.launchIO {
cancelUpNextEpisode()
val item = it.getAndAdvance()
val played = play(item.data, 0)
val played = play(item, 0)
if (!played) {
playUpNextUp()
}
@ -747,7 +748,7 @@ class PlaybackViewModel
viewModelScope.launchIO {
cancelUpNextEpisode()
val item = it.getPreviousAndReverse()
val played = play(item.data, 0)
val played = play(item, 0)
if (!played) {
playPrevious()
}
@ -765,7 +766,7 @@ class PlaybackViewModel
viewModelScope.launchIO {
val toPlay = playlist.advanceTo(item.id)
if (toPlay != null) {
val played = play(toPlay.data, 0)
val played = play(toPlay, 0)
if (!played) {
playUpNextUp()
}
@ -799,7 +800,7 @@ class PlaybackViewModel
PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> {
Timber.w("Playback error during ${it.playMethod}, falling back to transcoding")
changeStreams(
dto,
item,
currentItemPlayback.value!!,
currentItemPlayback.value?.audioIndex,
currentItemPlayback.value?.subtitleIndex,
@ -918,8 +919,12 @@ class PlaybackViewModel
while (maxAttempts > 0 && subtitleCount == newCount) {
maxAttempts--
delay(1500)
dto = api.userLibraryApi.getItem(itemId = dto.id).content
val mediaSource = chooseSource(dto, it)
item =
BaseItem.from(
api.userLibraryApi.getItem(itemId = item.id).content,
api,
)
val mediaSource = chooseSource(item.data, it)
if (mediaSource == null) {
// This shouldn't happen, but just in case
showToast(
@ -976,7 +981,7 @@ class PlaybackViewModel
audioIndex += 1
}
changeStreams(
dto,
item,
currentItemPlayback.value!!,
audioIndex,
newStream.index,
@ -1006,6 +1011,7 @@ class PlaybackViewModel
}
data class CurrentPlayback(
val item: BaseItem,
val tracks: List<TrackSupport>,
val playMethod: PlayMethod,
val playSessionId: String?,