Show local & remote trailers for TV Shows (#212)

Shows local & remote trailers for TV Shows similar to how its shown for
Movies

Closes #141
This commit is contained in:
damontecres 2025-11-13 18:31:23 -05:00 committed by GitHub
parent f61e819a98
commit fe29b0cd88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 126 additions and 46 deletions

View file

@ -1,6 +1,5 @@
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
@ -82,6 +81,7 @@ import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrailerService
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
@ -277,22 +277,8 @@ fun MovieDetails(
items = items, items = items,
) )
}, },
trailerOnClick = { trailer -> trailerOnClick = {
when (trailer) { TrailerService.onClick(context, it, viewModel::navigateTo)
is LocalTrailer ->
viewModel.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, modifier = modifier,
) )
@ -567,7 +553,7 @@ fun TrailerRow(
LazyRow( LazyRow(
state = state, state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -4,16 +4,13 @@ import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.Person 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.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -27,6 +24,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PeopleFavorites import com.github.damontecres.wholphin.util.PeopleFavorites
import com.github.damontecres.wholphin.util.ThemeSongPlayer import com.github.damontecres.wholphin.util.ThemeSongPlayer
import com.github.damontecres.wholphin.util.TrailerService
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -57,6 +55,7 @@ class MovieViewModel
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites, private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -108,31 +107,9 @@ class MovieViewModel
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val remoteTrailers = val trailers = trailerService.getTrailers(item)
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
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(itemId).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
}
} else {
listOf()
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers this@MovieViewModel.trailers.value = trailers
} }
} }
viewModelScope.launchIO { viewModelScope.launchIO {

View file

@ -46,6 +46,7 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
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.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -71,6 +72,7 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -79,6 +81,7 @@ import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.TrailerService
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
@ -102,6 +105,7 @@ fun SeriesDetails(
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
@ -133,6 +137,7 @@ fun SeriesDetails(
preferences = preferences, preferences = preferences,
series = item, series = item,
seasons = seasons, seasons = seasons,
trailers = trailers,
people = people, people = people,
similar = similar, similar = similar,
played = played, played = played,
@ -174,6 +179,9 @@ fun SeriesDetails(
val favorite = item.data.userData?.isFavorite ?: false val favorite = item.data.userData?.isFavorite ?: false
viewModel.setFavorite(item.id, !favorite, null) viewModel.setFavorite(item.id, !favorite, null)
}, },
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
moreActions = moreActions =
MoreDialogActions( MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) }, navigateTo = { viewModel.navigateTo(it) },
@ -244,7 +252,8 @@ fun SeriesDetails(
private const val HEADER_ROW = 0 private const val HEADER_ROW = 0
private const val SEASONS_ROW = HEADER_ROW + 1 private const val SEASONS_ROW = HEADER_ROW + 1
private const val PEOPLE_ROW = SEASONS_ROW + 1 private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val SIMILAR_ROW = PEOPLE_ROW + 1 private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val SIMILAR_ROW = TRAILER_ROW + 1
@Composable @Composable
fun SeriesDetailsContent( fun SeriesDetailsContent(
@ -252,6 +261,7 @@ fun SeriesDetailsContent(
series: BaseItem, series: BaseItem,
seasons: List<BaseItem>, seasons: List<BaseItem>,
similar: List<BaseItem>, similar: List<BaseItem>,
trailers: List<Trailer>,
people: List<Person>, people: List<Person>,
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
@ -262,6 +272,7 @@ fun SeriesDetailsContent(
playOnClick: () -> Unit, playOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
moreActions: MoreDialogActions, moreActions: MoreDialogActions,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -452,6 +463,21 @@ fun SeriesDetailsContent(
) )
} }
} }
if (trailers.isNotEmpty()) {
item {
TrailerRow(
trailers = trailers,
onClickTrailer = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[TRAILER_ROW]),
)
}
}
if (similar.isNotEmpty()) { if (similar.isNotEmpty()) {
item { item {
ItemRow( ItemRow(

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
@ -29,6 +30,7 @@ import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PeopleFavorites import com.github.damontecres.wholphin.util.PeopleFavorites
import com.github.damontecres.wholphin.util.ThemeSongPlayer import com.github.damontecres.wholphin.util.ThemeSongPlayer
import com.github.damontecres.wholphin.util.TrailerService
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -62,12 +64,14 @@ class SeriesViewModel
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites, private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
) : ItemViewModel(api) { ) : ItemViewModel(api) {
private lateinit var seriesId: UUID private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading) val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem>>(listOf()) val seasons = MutableLiveData<List<BaseItem>>(listOf())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading) val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
@ -111,6 +115,12 @@ class SeriesViewModel
episodes.value = episodeInfo episodes.value = episodeInfo
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = trailers
}
}
viewModelScope.launchIO { viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item) val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people) this@SeriesViewModel.people.setValueOnMain(people)

View file

@ -0,0 +1,81 @@
package com.github.damontecres.wholphin.util
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TrailerService
@Inject
constructor(
@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
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: context.getString(R.string.trailer)
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(item.id).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
}
} else {
listOf()
}
return localTrailers + remoteTrailers
}
companion object {
/**
* Note: This is explicitly <b>not</b> a member function because the injected Context is not an Activity.
* We want to start the intent without a new task which requires the Activity context
*
* This can be provided by LocalContext.current from Compose
*/
fun onClick(
context: Context,
trailer: Trailer,
navigateTo: (Destination) -> Unit,
) {
when (trailer) {
is LocalTrailer ->
navigateTo.invoke(
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)
}
}
}
}
}