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
This commit is contained in:
damontecres 2025-10-21 18:09:57 -04:00 committed by GitHub
parent 2770ae1e4a
commit 6344cc77bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 196 additions and 41 deletions

View file

@ -43,8 +43,47 @@ fun SeasonCard(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false, showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f), 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 focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
@ -89,13 +128,13 @@ fun SeasonCard(
.fillMaxSize(), .fillMaxSize(),
) { ) {
ItemCardImage( ItemCardImage(
imageUrl = item?.imageUrl, imageUrl = imageUrl,
name = item?.name, name = name,
showOverlay = showImageOverlay, showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false, favorite = isFavorite,
watched = dto?.userData?.played ?: false, watched = isPlayed,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, unwatchedCount = unplayedItemCount,
watchedPercent = dto?.userData?.playedPercentage, watchedPercent = playedPercentage,
useFallbackText = false, useFallbackText = false,
modifier = modifier =
Modifier Modifier
@ -111,7 +150,7 @@ fun SeasonCard(
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
Text( Text(
text = item?.title ?: "", text = title ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =
@ -121,7 +160,7 @@ fun SeasonCard(
.enableMarquee(focusedAfterDelay), .enableMarquee(focusedAfterDelay),
) )
Text( Text(
text = item?.subtitle ?: "", text = subtitle ?: "",
maxLines = 1, maxLines = 1,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = modifier =

View file

@ -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

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail.movie package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.runtime.Composable 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.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ChapterRow 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.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DialogParams 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.components.chooseVersionParams
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo 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.LoadingItemViewModel
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -96,7 +106,7 @@ class MovieViewModel
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf()) val chapters = MutableLiveData<List<Chapter>>(listOf())
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val trailers = MutableLiveData<List<BaseItem>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
override fun init( override fun init(
itemId: UUID, itemId: UUID,
@ -111,15 +121,31 @@ class MovieViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = result 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 val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) { if (localTrailerCount > 0) {
val trailers =
api.userLibraryApi.getLocalTrailers(itemId).content.map { api.userLibraryApi.getLocalTrailers(itemId).content.map {
BaseItem.from(it, api) LocalTrailer(BaseItem.from(it, api))
}
} else {
listOf()
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = trailers this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
}
} }
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -192,6 +218,7 @@ fun MovieDetails(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MovieViewModel = hiltViewModel(), viewModel: MovieViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(destination.itemId, destination.item) viewModel.init(destination.itemId, destination.item)
} }
@ -291,13 +318,21 @@ fun MovieDetails(
viewModel.setWatched((movie.data.userData?.played ?: false).not()) viewModel.setWatched((movie.data.userData?.played ?: false).not())
}, },
trailerOnClick = { trailer -> trailerOnClick = { trailer ->
when (trailer) {
is LocalTrailer ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
itemId = trailer.id, itemId = trailer.baseItem.id,
item = trailer, item = trailer.baseItem,
positionMs = 0L, positionMs = 0L,
), ),
) )
is RemoteTrailer -> {
val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri())
context.startActivity(intent)
}
}
}, },
modifier = modifier, modifier = modifier,
) )
@ -339,9 +374,9 @@ fun MovieDetailsContent(
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
people: List<Person>, people: List<Person>,
chapters: List<Chapter>, chapters: List<Chapter>,
trailers: List<BaseItem>, trailers: List<Trailer>,
playOnClick: (Duration) -> Unit, playOnClick: (Duration) -> Unit,
trailerOnClick: (BaseItem) -> Unit, trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
@ -440,22 +475,10 @@ fun MovieDetailsContent(
} }
if (trailers.isNotEmpty()) { if (trailers.isNotEmpty()) {
item { item {
ItemRow( TrailerRow(
title = stringResource(R.string.trailers), trailers = trailers,
items = trailers, onClickTrailer = trailerOnClick,
onClickItem = trailerOnClick, modifier = Modifier.fillMaxWidth(),
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,
)
},
) )
} }
} }
@ -472,3 +495,77 @@ fun MovieDetailsContent(
} }
} }
} }
@Composable
fun TrailerRow(
trailers: List<Trailer>,
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,
)
}
}
}
}
}
}