Support playing local movie trailers (#36)

Adds support for playing local movie trailers. External trailers, such
as ones on YouTube, are not yet supported.

Also fixes a couple issues with request handlers
This commit is contained in:
damontecres 2025-10-19 00:15:45 -04:00 committed by GitHub
parent bb19ba316b
commit 63baf7f904
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 61 additions and 2 deletions

View file

@ -35,7 +35,7 @@ fun ChapterRow(
LazyRow( LazyRow(
state = rememberLazyListState(), state = rememberLazyListState(),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

@ -28,12 +28,15 @@ import androidx.compose.ui.focus.focusRequester
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.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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 coil3.compose.AsyncImage import coil3.compose.AsyncImage
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.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
@ -42,8 +45,11 @@ 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.chooseSource 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.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.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -70,6 +76,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUID
@ -89,6 +96,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())
override fun init( override fun init(
itemId: UUID, itemId: UUID,
@ -103,6 +111,16 @@ class MovieViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
chosenStreams.value = result chosenStreams.value = result
} }
val localTrailerCount = item.data.localTrailerCount ?: 0
if (localTrailerCount > 0) {
val trailers =
api.userLibraryApi.getLocalTrailers(itemId).content.map {
BaseItem.from(it, api)
}
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = trailers
}
}
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
people.value = people.value =
@ -180,6 +198,7 @@ fun MovieDetails(
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val chapters by viewModel.chapters.observeAsState(listOf()) val chapters by viewModel.chapters.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams by viewModel.chosenStreams.observeAsState(null)
@ -200,6 +219,7 @@ fun MovieDetails(
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
people = people, people = people,
chapters = chapters, chapters = chapters,
trailers = trailers,
playOnClick = { playOnClick = {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.Playback( Destination.Playback(
@ -270,6 +290,15 @@ fun MovieDetails(
watchOnClick = { watchOnClick = {
viewModel.setWatched((movie.data.userData?.played ?: false).not()) viewModel.setWatched((movie.data.userData?.played ?: false).not())
}, },
trailerOnClick = { trailer ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = trailer.id,
item = trailer,
positionMs = 0L,
),
)
},
modifier = modifier, modifier = modifier,
) )
} }
@ -310,7 +339,9 @@ fun MovieDetailsContent(
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
people: List<Person>, people: List<Person>,
chapters: List<Chapter>, chapters: List<Chapter>,
trailers: List<BaseItem>,
playOnClick: (Duration) -> Unit, playOnClick: (Duration) -> Unit,
trailerOnClick: (BaseItem) -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
@ -359,7 +390,7 @@ fun MovieDetailsContent(
} }
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(32.dp), contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
item { item {
@ -407,6 +438,27 @@ 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,
)
},
)
}
}
if (chapters.isNotEmpty()) { if (chapters.isNotEmpty()) {
item { item {
ChapterRow( ChapterRow(

View file

@ -476,6 +476,7 @@ class PlaybackViewModel
it.release() it.release()
player.removeListener(it) player.removeListener(it)
} }
val activityListener = val activityListener =
TrackActivityPlaybackListener( TrackActivityPlaybackListener(
api = api, api = api,

View file

@ -230,6 +230,7 @@ val GetResumeItemsRequestHandler =
request.copy( request.copy(
startIndex = startIndex, startIndex = startIndex,
limit = limit, limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
) )
override suspend fun execute( override suspend fun execute(
@ -249,6 +250,7 @@ val GetNextUpRequestHandler =
request.copy( request.copy(
startIndex = startIndex, startIndex = startIndex,
limit = limit, limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
) )
override suspend fun execute( override suspend fun execute(
@ -268,6 +270,7 @@ val GetSuggestionsRequestHandler =
request.copy( request.copy(
startIndex = startIndex, startIndex = startIndex,
limit = limit, limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
) )
override suspend fun execute( override suspend fun execute(
@ -306,6 +309,7 @@ val GetGenresRequestHandler =
request.copy( request.copy(
startIndex = startIndex, startIndex = startIndex,
limit = limit, limit = limit,
enableTotalRecordCount = enableTotalRecordCount,
) )
override suspend fun execute( override suspend fun execute(

View file

@ -20,6 +20,7 @@ val supportItemKinds =
BaseItemKind.SEASON, BaseItemKind.SEASON,
BaseItemKind.COLLECTION_FOLDER, BaseItemKind.COLLECTION_FOLDER,
BaseItemKind.USER_VIEW, BaseItemKind.USER_VIEW,
BaseItemKind.TRAILER,
) )
val supportedCollectionTypes = val supportedCollectionTypes =

View file

@ -82,5 +82,6 @@
<string name="playback_overrides">Playback overrides</string> <string name="playback_overrides">Playback overrides</string>
<string name="direct_play_ass">Direct play ASS subtitles</string> <string name="direct_play_ass">Direct play ASS subtitles</string>
<string name="direct_play_pgs">Direct play PGS subtitles</string> <string name="direct_play_pgs">Direct play PGS subtitles</string>
<string name="trailers">Trailers</string>
</resources> </resources>