From 6344cc77bf621f20c6f882adb0835c4098966e77 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 21 Oct 2025 18:09:57 -0400 Subject: [PATCH] Support remote movie trailers (#50) Show remote trailers on movie details page. They play in a separate compatible app such as YouTube. Local trailers are shown first --- .../wholphin/ui/cards/SeasonCard.kt | 57 ++++++- .../damontecres/wholphin/ui/data/Trailer.kt | 19 +++ .../wholphin/ui/detail/movie/MovieDetails.kt | 161 ++++++++++++++---- 3 files changed, 196 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/data/Trailer.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 566d0d20..22ad8b1b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -43,8 +43,47 @@ fun SeasonCard( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, showImageOverlay: Boolean = false, aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f), +) = SeasonCard( + title = item?.title, + subtitle = item?.subtitle, + name = item?.name, + imageUrl = item?.imageUrl, + isFavorite = item?.data?.userData?.isFavorite ?: false, + isPlayed = item?.data?.userData?.played ?: false, + unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, + playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + imageHeight = imageHeight, + imageWidth = imageWidth, + interactionSource = interactionSource, + showImageOverlay = showImageOverlay, + aspectRatio = aspectRatio, +) + +/** + * A Card for a TV Show Season, but can generically show most items + */ +@Composable +fun SeasonCard( + title: String?, + subtitle: String?, + name: String?, + imageUrl: String?, + isFavorite: Boolean, + isPlayed: Boolean, + unplayedItemCount: Int, + playedPercentage: Double, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + imageHeight: Dp = Dp.Unspecified, + imageWidth: Dp = Dp.Unspecified, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + showImageOverlay: Boolean = false, + aspectRatio: Float = (2f / 3f), ) { - val dto = item?.data val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) @@ -89,13 +128,13 @@ fun SeasonCard( .fillMaxSize(), ) { ItemCardImage( - imageUrl = item?.imageUrl, - name = item?.name, + imageUrl = imageUrl, + name = name, showOverlay = showImageOverlay, - favorite = dto?.userData?.isFavorite ?: false, - watched = dto?.userData?.played ?: false, - unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, - watchedPercent = dto?.userData?.playedPercentage, + favorite = isFavorite, + watched = isPlayed, + unwatchedCount = unplayedItemCount, + watchedPercent = playedPercentage, useFallbackText = false, modifier = Modifier @@ -111,7 +150,7 @@ fun SeasonCard( .fillMaxWidth(), ) { Text( - text = item?.title ?: "", + text = title ?: "", maxLines = 1, textAlign = TextAlign.Center, modifier = @@ -121,7 +160,7 @@ fun SeasonCard( .enableMarquee(focusedAfterDelay), ) Text( - text = item?.subtitle ?: "", + text = subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/Trailer.kt new file mode 100644 index 00000000..d0637d9b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/Trailer.kt @@ -0,0 +1,19 @@ +package com.github.damontecres.wholphin.ui.data + +import com.github.damontecres.wholphin.data.model.BaseItem + +sealed interface Trailer { + val name: String +} + +data class LocalTrailer( + val baseItem: BaseItem, +) : Trailer { + override val name: String + get() = baseItem.name ?: "" +} + +data class RemoteTrailer( + override val name: String, + val url: String, +) : Trailer diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 3e019c69..8add8b02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.movie +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,6 +10,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable @@ -25,16 +29,20 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams @@ -47,7 +55,6 @@ import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.ChapterRow -import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.DialogParams @@ -59,6 +66,9 @@ import com.github.damontecres.wholphin.ui.components.chooseStream import com.github.damontecres.wholphin.ui.components.chooseVersionParams import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.data.LocalTrailer +import com.github.damontecres.wholphin.ui.data.RemoteTrailer +import com.github.damontecres.wholphin.ui.data.Trailer import com.github.damontecres.wholphin.ui.detail.LoadingItemViewModel import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -96,7 +106,7 @@ class MovieViewModel val people = MutableLiveData>(listOf()) val chapters = MutableLiveData>(listOf()) val chosenStreams = MutableLiveData(null) - val trailers = MutableLiveData>(listOf()) + val trailers = MutableLiveData>(listOf()) override fun init( itemId: UUID, @@ -111,15 +121,31 @@ class MovieViewModel withContext(Dispatchers.Main) { chosenStreams.value = result } + val remoteTrailers = + item.data.remoteTrailers + ?.mapNotNull { t -> + t.url?.let { url -> + val name = + t.name + // TODO would be nice to clean up the trailer name +// ?.replace(item.name ?: "", "") +// ?.removePrefix(" - ") + ?: "Trailer" + RemoteTrailer(name, url) + } + }.orEmpty() + .sortedBy { it.name } val localTrailerCount = item.data.localTrailerCount ?: 0 - if (localTrailerCount > 0) { - val trailers = + val localTrailers = + if (localTrailerCount > 0) { api.userLibraryApi.getLocalTrailers(itemId).content.map { - BaseItem.from(it, api) + LocalTrailer(BaseItem.from(it, api)) } - withContext(Dispatchers.Main) { - this@MovieViewModel.trailers.value = trailers + } else { + listOf() } + withContext(Dispatchers.Main) { + this@MovieViewModel.trailers.value = localTrailers + remoteTrailers } } withContext(Dispatchers.Main) { @@ -192,6 +218,7 @@ fun MovieDetails( modifier: Modifier = Modifier, viewModel: MovieViewModel = hiltViewModel(), ) { + val context = LocalContext.current LaunchedEffect(Unit) { viewModel.init(destination.itemId, destination.item) } @@ -291,13 +318,21 @@ fun MovieDetails( viewModel.setWatched((movie.data.userData?.played ?: false).not()) }, trailerOnClick = { trailer -> - viewModel.navigationManager.navigateTo( - Destination.Playback( - itemId = trailer.id, - item = trailer, - positionMs = 0L, - ), - ) + when (trailer) { + is LocalTrailer -> + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = trailer.baseItem.id, + item = trailer.baseItem, + positionMs = 0L, + ), + ) + + is RemoteTrailer -> { + val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri()) + context.startActivity(intent) + } + } }, modifier = modifier, ) @@ -339,9 +374,9 @@ fun MovieDetailsContent( chosenStreams: ChosenStreams?, people: List, chapters: List, - trailers: List, + trailers: List, playOnClick: (Duration) -> Unit, - trailerOnClick: (BaseItem) -> Unit, + trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, watchOnClick: () -> Unit, moreOnClick: () -> Unit, @@ -440,22 +475,10 @@ fun MovieDetailsContent( } if (trailers.isNotEmpty()) { item { - ItemRow( - title = stringResource(R.string.trailers), - items = trailers, - onClickItem = trailerOnClick, - onLongClickItem = {}, - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - imageWidth = Dp.Unspecified, - showImageOverlay = false, - modifier = mod, - ) - }, + TrailerRow( + trailers = trailers, + onClickTrailer = trailerOnClick, + modifier = Modifier.fillMaxWidth(), ) } } @@ -472,3 +495,77 @@ fun MovieDetailsContent( } } } + +@Composable +fun TrailerRow( + trailers: List, + onClickTrailer: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.trailers), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(trailers) { index, item -> + val cardModifier = + if (index == 0) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + when (item) { + is LocalTrailer -> + SeasonCard( + item = item.baseItem, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + showImageOverlay = false, + modifier = cardModifier, + ) + + is RemoteTrailer -> { + val subtitle = + when (item.url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + SeasonCard( + title = item.name, + subtitle = subtitle, + name = item.name, + imageUrl = null, + isFavorite = false, + isPlayed = false, + unplayedItemCount = 0, + playedPercentage = 0.0, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + modifier = cardModifier, + showImageOverlay = false, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + } + } + } + } + } +}