Show button for trailers instead of card row (#618)

## Description
On both movie & series detail pages, show a button to play trailers
instead of the row of cards below.

The button works like this:
- If no trailers, the button shows "No trailers" and is
disabled/non-clickable
- If only a single trailer, the button shows "Play trailer" and clicking
it plays the trailer
- Otherwise, shows "Trailers" and clicking displays a dialog to choose
one

Local trailers are shown first in the list. Then there is an attempt to
sort the trailers for the dialog so that names with "Official Trailer"
or "Official Theatrical Trailer" are listed first and "Teaser" is listed
last. This isn't perfect and doesn't work for non-English, but there's
no other metadata to use.

### Related issues
Closes #408
This commit is contained in:
Ray 2026-01-02 13:50:45 -05:00 committed by GitHub
parent b8e8a1d913
commit 0fe8e5d988
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 218 additions and 170 deletions

View file

@ -14,4 +14,5 @@ data class LocalTrailer(
data class RemoteTrailer(
override val name: String,
val url: String,
val subtitle: String?,
) : Trailer

View file

@ -22,31 +22,53 @@ class TrailerService
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
suspend fun getTrailers(item: BaseItem): List<Trailer> {
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
fun getRemoteTrailers(item: BaseItem): List<Trailer> =
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(" - ")
?: context.getString(R.string.trailer)
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
?: context.getString(R.string.trailer)
val subtitle =
when (url.toUri().host) {
"youtube.com", "www.youtube.com" -> "YouTube"
else -> null
}
RemoteTrailer(name, url, subtitle)
}
}.orEmpty()
.sortedWith(
compareBy(
{
// Try to show official trailers first & teasers last
when {
it.name.contains("Official Trailer", true) -> 0
it.name.contains("Official Theatrical Trailer", true) -> 0
it.name.contains("Teaser", true) -> 10
it.name.contains("Trailer", true) -> 1
else -> 5
}
},
{
it.name
},
),
)
suspend fun getLocalTrailers(item: BaseItem): List<Trailer> {
val localTrailerCount = item.data.localTrailerCount ?: return emptyList()
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(item.id).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
LocalTrailer(BaseItem.from(it, api))
}
} else {
listOf()
}
return localTrailers + remoteTrailers
return localTrailers
}
companion object {

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@ -239,47 +240,48 @@ fun DialogPopupContent(
) {
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
LazyColumn(
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
// .widthIn(min = 520.dp, max = 300.dp)
// .dialogFocusable()
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
) {
stickyHeader {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
items(dialogItems) {
when (it) {
is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp))
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
LazyColumn(
modifier = Modifier,
) {
items(dialogItems) {
when (it) {
is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp))
}
is DialogItem -> {
ListItem(
selected = false,
enabled = !waiting && it.enabled,
onClick = {
if (dismissOnClick) {
onDismissRequest.invoke()
}
it.onClick.invoke()
},
headlineContent = it.headlineContent,
overlineContent = it.overlineContent,
supportingContent = it.supportingContent,
leadingContent = it.leadingContent,
trailingContent = it.trailingContent,
modifier = Modifier,
)
is DialogItem -> {
ListItem(
selected = false,
enabled = !waiting && it.enabled,
onClick = {
if (dismissOnClick) {
onDismissRequest.invoke()
}
it.onClick.invoke()
},
headlineContent = it.headlineContent,
overlineContent = it.overlineContent,
supportingContent = it.supportingContent,
leadingContent = it.leadingContent,
trailingContent = it.trailingContent,
modifier = Modifier,
)
}
}
}
}

View file

@ -21,7 +21,10 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -40,6 +43,7 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.data.SortAndDirection
@ -59,10 +63,12 @@ fun ExpandablePlayButtons(
resumePosition: Duration,
watched: Boolean,
favorite: Boolean,
trailers: List<Trailer>?,
playOnClick: (position: Duration) -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
) {
@ -134,6 +140,16 @@ fun ExpandablePlayButtons(
)
}
if (trailers != null) {
item("trailers") {
TrailerButton(
trailers = trailers,
trailerOnClick = trailerOnClick,
modifier = Modifier.onFocusChanged(buttonOnFocusChanged),
)
}
}
// More button
item("more") {
ExpandablePlayButton(
@ -213,6 +229,7 @@ fun ExpandableFaButton(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
iconColor: Color = Color.Unspecified,
enabled: Boolean = true,
) {
val isFocused = interactionSource.collectIsFocusedAsState().value
Button(
@ -225,6 +242,7 @@ fun ExpandableFaButton(
),
contentPadding = DefaultButtonPadding,
interactionSource = interactionSource,
enabled = enabled,
) {
Box(
modifier =
@ -251,6 +269,42 @@ fun ExpandableFaButton(
}
}
@Composable
fun TrailerButton(
trailers: List<Trailer>,
trailerOnClick: (Trailer) -> Unit,
modifier: Modifier = Modifier,
) {
var showDialog by remember { mutableStateOf(false) }
ExpandableFaButton(
title =
if (trailers.isEmpty()) {
R.string.no_trailers
} else if (trailers.size == 1) {
R.string.play_trailer
} else {
R.string.trailers
},
iconStringRes = R.string.fa_film,
enabled = trailers.isNotEmpty(),
onClick = {
if (trailers.size == 1) {
trailerOnClick.invoke(trailers.first())
} else {
showDialog = true
}
},
modifier = modifier,
)
if (showDialog) {
TrailerDialog(
onDismissRequest = { showDialog = false },
trailers = trailers,
onClick = trailerOnClick,
)
}
}
@PreviewTvSpec
@Composable
private fun ExpandablePlayButtonsPreview() {
@ -264,6 +318,8 @@ private fun ExpandablePlayButtonsPreview() {
favoriteOnClick = {},
moreOnClick = {},
buttonOnFocusChanged = {},
trailers = listOf(),
trailerOnClick = {},
modifier = Modifier,
)
}

View file

@ -0,0 +1,51 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
@Composable
fun TrailerDialog(
onDismissRequest: () -> Unit,
trailers: List<Trailer>,
onClick: (Trailer) -> Unit,
) {
val trailersStr = stringResource(R.string.play_trailer)
val localStr = stringResource(R.string.local)
val externalStr = stringResource(R.string.external_track)
val params =
remember(trailers) {
DialogParams(
fromLongClick = false,
title = trailersStr,
items =
trailers.map { trailer ->
DialogItem(
headlineContent = {
Text(trailer.name)
},
supportingContent = {
val subtitle =
when (trailer) {
is LocalTrailer -> localStr
is RemoteTrailer -> trailer.subtitle ?: externalStr
}
Text(
text = subtitle,
)
},
onClick = { onClick.invoke(trailer) },
)
},
)
}
DialogPopup(
params = params,
onDismissRequest = onDismissRequest,
)
}

View file

@ -334,6 +334,8 @@ fun EpisodeDetailsContent(
}
}
},
trailers = null,
trailerOnClick = {},
modifier =
Modifier
.fillMaxWidth()

View file

@ -8,9 +8,6 @@ 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
@ -23,24 +20,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
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.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
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.preferences.UserPreferences
@ -436,6 +427,11 @@ fun MovieDetailsContent(
}
}
},
trailers = trailers,
trailerOnClick = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
modifier =
Modifier
.fillMaxWidth()
@ -463,21 +459,6 @@ fun MovieDetailsContent(
)
}
}
if (trailers.isNotEmpty()) {
item {
TrailerRow(
trailers = trailers,
onClickTrailer = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[TRAILER_ROW]),
)
}
}
if (chapters.isNotEmpty()) {
item {
ChapterRow(
@ -545,78 +526,3 @@ 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 = 24.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,
)
}
}
}
}
}
}

View file

@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -116,16 +117,19 @@ class MovieViewModel
item,
userPreferencesService.getCurrent(),
)
val remoteTrailers = trailerService.getRemoteTrailers(item)
withContext(Dispatchers.Main) {
this@MovieViewModel.item.value = item
chosenStreams.value = result
this@MovieViewModel.trailers.value = remoteTrailers
loading.value = LoadingState.Success
backdropService.submit(item)
}
viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = trailers
trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
}
}
}
viewModelScope.launchIO {

View file

@ -45,6 +45,8 @@ fun FocusedEpisodeFooter(
watchOnClick = watchOnClick,
favoriteOnClick = favoriteOnClick,
buttonOnFocusChanged = buttonOnFocusChanged,
trailers = null,
trailerOnClick = {},
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails
import com.github.damontecres.wholphin.ui.components.TrailerButton
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -70,7 +71,6 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
@ -411,6 +411,18 @@ fun SeriesDetailsContent(
}
},
)
TrailerButton(
trailers = trailers,
trailerOnClick = trailerOnClick,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
}
item {
@ -472,21 +484,6 @@ fun SeriesDetailsContent(
)
}
}
if (trailers.isNotEmpty()) {
item {
TrailerRow(
trailers = trailers,
onClickTrailer = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[TRAILER_ROW]),
)
}
}
if (extras.isNotEmpty()) {
item {
ExtrasRow(

View file

@ -165,8 +165,9 @@ class SeriesViewModel
}
}
}
val remoteTrailers = trailerService.getRemoteTrailers(item)
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = remoteTrailers
this@SeriesViewModel.position.update {
it.copy(
episodeRowIndex =
@ -179,9 +180,10 @@ class SeriesViewModel
}
if (seriesPageType == SeriesPageType.DETAILS) {
viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = trailers
trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers
}
}
}
viewModelScope.launchIO {