Show extras for Movies & TV Shows (#224)

## Description
Shows local extras (clips, interviews, etc) for movies and tv shows

Extras are grouped by type and clicking on the type will go to a grid
page listing all of the items. However, if there is just one of that
type, it is shown in-line.

Trailers still have on their own row. And theme songs & theme videos are
not shown.

## Issues
Closes #123
This commit is contained in:
damontecres 2025-11-15 13:25:44 -05:00 committed by GitHub
parent 2f56a3a114
commit 93d82a263d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 542 additions and 36 deletions

View file

@ -0,0 +1,78 @@
package com.github.damontecres.wholphin.data
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.ExtraType
sealed interface ExtrasItem {
val parentId: UUID
val type: ExtraType
val destination: Destination
val title: String?
val imageUrl: String?
data class Group(
override val parentId: UUID,
override val type: ExtraType,
val items: List<BaseItem>,
) : ExtrasItem {
override val destination: Destination =
Destination.ItemGrid(null, type.stringRes, items.map { it.id })
override val title: String? = null
override val imageUrl: String? = items.random().imageUrl
}
data class Single(
override val parentId: UUID,
override val type: ExtraType,
val item: BaseItem,
) : ExtrasItem {
override val destination: Destination =
Destination.Playback(
item = item,
)
override val title: String? get() = item.title
override val imageUrl: String? get() = item.imageUrl
}
}
@get:StringRes
val ExtraType.stringRes: Int
get() =
when (this) {
ExtraType.UNKNOWN -> R.string.other_extras
ExtraType.CLIP -> R.string.clips
ExtraType.TRAILER -> R.string.trailers
ExtraType.BEHIND_THE_SCENES -> R.string.behind_the_scenes
ExtraType.DELETED_SCENE -> R.string.deleted_scenes
ExtraType.INTERVIEW -> R.string.interviews
ExtraType.SCENE -> R.string.scenes
ExtraType.SAMPLE -> R.string.samples
ExtraType.THEME_SONG -> R.string.theme_songs
ExtraType.THEME_VIDEO -> R.string.theme_videos
ExtraType.FEATURETTE -> R.string.featurettes
ExtraType.SHORT -> R.string.shorts
}
@get:PluralsRes
val ExtraType.pluralRes: Int
get() =
when (this) {
ExtraType.UNKNOWN -> R.plurals.other_extras
ExtraType.CLIP -> R.plurals.clips
ExtraType.TRAILER -> R.plurals.trailers
ExtraType.BEHIND_THE_SCENES -> R.plurals.behind_the_scenes
ExtraType.DELETED_SCENE -> R.plurals.deleted_scenes
ExtraType.INTERVIEW -> R.plurals.interviews
ExtraType.SCENE -> R.plurals.scenes
ExtraType.SAMPLE -> R.plurals.samples
ExtraType.THEME_SONG -> R.plurals.theme_songs
ExtraType.THEME_VIDEO -> R.plurals.theme_videos
ExtraType.FEATURETTE -> R.plurals.featurettes
ExtraType.SHORT -> R.plurals.shorts
}

View file

@ -0,0 +1,60 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.ExtraType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ExtrasService
@Inject
constructor(
private val api: ApiClient,
) {
suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
val extrasMap =
api.userLibraryApi
.getSpecialFeatures(itemId)
.content
.filterNot {
it.extraType == ExtraType.THEME_SONG ||
it.extraType == ExtraType.THEME_VIDEO ||
it.extraType == ExtraType.TRAILER
}.map { BaseItem.from(it, api) }
.groupBy { it.data.extraType ?: ExtraType.UNKNOWN }
val result =
extrasMap
.mapNotNull { (type, items) ->
if (items.size == 1) {
ExtrasItem.Single(itemId, type, items.first())
} else if (items.size > 1) {
ExtrasItem.Group(itemId, type, items)
} else {
null
}
}.sortedBy { it.type.sortOrder }
return result
}
}
private val ExtraType.sortOrder: Int
get() =
when (this) {
ExtraType.TRAILER -> 0
ExtraType.FEATURETTE -> 1
ExtraType.SHORT -> 2
ExtraType.CLIP -> 3
ExtraType.SCENE -> 4
ExtraType.SAMPLE -> 5
ExtraType.DELETED_SCENE -> 6
ExtraType.INTERVIEW -> 7
ExtraType.BEHIND_THE_SCENES -> 8
ExtraType.THEME_SONG -> 9
ExtraType.THEME_VIDEO -> 10
ExtraType.UNKNOWN -> 11
}

View file

@ -0,0 +1,62 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.pluralRes
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
@Composable
fun ExtrasRow(
extras: List<ExtrasItem>,
onClickItem: (Int, ExtrasItem) -> Unit,
onLongClickItem: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val resources = LocalResources.current
ItemRow(
title = stringResource(R.string.extras),
items = extras,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
title =
when (item) {
is ExtrasItem.Group -> item.type.pluralRes.let { resources.getQuantityString(it, item.items.size) }
is ExtrasItem.Single -> item.title ?: item.type.pluralRes.let { resources.getQuantityString(it, 1) }
null -> null
},
name = null,
subtitle =
if (item is ExtrasItem.Single && item.title != null) {
item.type.pluralRes.let { resources.getQuantityString(it, 1) }
} else if (item is ExtrasItem.Group) {
resources.getQuantityString(R.plurals.items, item.items.size, item.items.size)
} else {
null
},
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
showImageOverlay = true,
imageHeight = Cards.height2x3 * .75f,
imageWidth = Dp.Unspecified,
imageUrl = item?.imageUrl,
isFavorite = false,
isPlayed = false,
unplayedItemCount = -1,
playedPercentage = -1.0,
aspectRatio = AspectRatios.FOUR_THREE, // TODO
)
},
modifier = modifier,
)
}

View file

@ -26,14 +26,14 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.util.FocusPair
@Composable
fun ItemRow(
fun <T> ItemRow(
title: String,
items: List<BaseItem?>,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
items: List<T?>,
onClickItem: (Int, T) -> Unit,
onLongClickItem: (Int, T) -> Unit,
cardContent: @Composable (
index: Int,
item: BaseItem?,
item: T?,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,

View file

@ -0,0 +1,142 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
@HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class)
class ItemGridViewModel
@AssistedInject
constructor(
private val api: ApiClient,
private val navigationManager: NavigationManager,
@Assisted private val destination: Destination.ItemGrid,
) : ViewModel() {
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val items = MutableLiveData<List<BaseItem?>>(listOf())
@AssistedFactory
interface Factory {
fun create(destination: Destination.ItemGrid): ItemGridViewModel
}
init {
viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) {
val request =
GetItemsRequest(
ids = destination.itemIds,
sortBy = listOf(ItemSortBy.SORT_NAME),
sortOrder = listOf(SortOrder.ASCENDING),
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init()
if (pager.isNotEmpty()) {
pager.getBlocking(0)
}
withContext(Dispatchers.Main) {
this@ItemGridViewModel.items.value = pager
this@ItemGridViewModel.loading.value = LoadingState.Success
}
}
}
fun navigateTo(destination: Destination) {
navigationManager.navigateTo(destination)
}
}
@Composable
fun ItemGrid(
destination: Destination.ItemGrid,
modifier: Modifier = Modifier,
viewModel: ItemGridViewModel =
hiltViewModel<ItemGridViewModel, ItemGridViewModel.Factory>(
creationCallback = { it.create(destination) },
),
) {
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val items by viewModel.items.observeAsState(listOf())
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading,
LoadingState.Pending,
-> LoadingPage()
LoadingState.Success -> {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.tryRequestFocus()
}
Column(modifier = modifier) {
Text(
text = destination.title ?: destination.titleRes?.let { stringResource(it) } ?: "",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
CardGrid(
pager = items,
onClickItem = { index: Int, item: BaseItem ->
// TODO handle more types
viewModel.navigateTo(Destination.Playback(item.id, 0))
},
onLongClickItem = { index: Int, item: BaseItem -> },
letterPosition = { c: Char -> 0 },
gridFocusRequester = focusRequester,
showJumpButtons = false,
showLetterButtons = false,
spacing = 24.dp,
cardContent = @Composable { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
imageAspectRatio = AspectRatios.WIDE, // TODO
)
},
columns = 3,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}

View file

@ -44,6 +44,7 @@ import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
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.tv.material3.Button
import androidx.tv.material3.LocalContentColor
@ -93,9 +94,10 @@ fun CardGrid(
modifier = mod,
)
},
columns: Int = 6,
spacing: Dp = 16.dp,
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val columns = 6
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow)
@ -232,8 +234,8 @@ fun CardGrid(
) {
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(spacing),
state = gridState,
contentPadding = PaddingValues(16.dp),
modifier =

View file

@ -45,6 +45,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage
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
@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -112,6 +114,7 @@ fun MovieDetails(
val people by viewModel.people.observeAsState(listOf())
val chapters by viewModel.chapters.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
@ -159,6 +162,7 @@ fun MovieDetails(
chosenStreams = chosenStreams,
people = people,
chapters = chapters,
extras = extras,
trailers = trailers,
similar = similar,
onClickItem = { index, item ->
@ -282,6 +286,9 @@ fun MovieDetails(
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
onClickExtra = { index, extra ->
viewModel.navigateTo(extra.destination)
},
modifier = modifier,
)
}
@ -340,7 +347,8 @@ private const val HEADER_ROW = 0
private const val PEOPLE_ROW = HEADER_ROW + 1
private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val CHAPTER_ROW = TRAILER_ROW + 1
private const val SIMILAR_ROW = CHAPTER_ROW + 1
private const val EXTRAS_ROW = CHAPTER_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1
@Composable
fun MovieDetailsContent(
@ -350,6 +358,7 @@ fun MovieDetailsContent(
people: List<Person>,
chapters: List<Chapter>,
trailers: List<Trailer>,
extras: List<ExtrasItem>,
similar: List<BaseItem>,
playOnClick: (Duration) -> Unit,
trailerOnClick: (Trailer) -> Unit,
@ -361,8 +370,10 @@ fun MovieDetailsContent(
onClickPerson: (Person) -> Unit,
onLongClickPerson: (Int, Person) -> Unit,
onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var position by rememberInt(0)
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
@ -501,6 +512,22 @@ fun MovieDetailsContent(
)
}
}
if (extras.isNotEmpty()) {
item {
ExtrasRow(
extras = extras,
onClickItem = { index, item ->
position = EXTRAS_ROW
onClickExtra.invoke(index, item)
},
onLongClickItem = { _, _ -> },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[EXTRAS_ROW]),
)
}
}
if (similar.isNotEmpty()) {
item {
ItemRow(

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
@ -13,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
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.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
@ -56,6 +58,7 @@ class MovieViewModel
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
private val extrasService: ExtrasService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -68,6 +71,7 @@ class MovieViewModel
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
@ -116,6 +120,11 @@ class MovieViewModel
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(item.id)
this@MovieViewModel.extras.setValueOnMain(extras)
}
withContext(Dispatchers.Main) {
chapters.value = Chapter.fromDto(item.data, api)
}

View file

@ -44,12 +44,14 @@ 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.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
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.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
@ -99,13 +101,14 @@ fun SeriesDetails(
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.init(preferences, destination.itemId, destination.item, null)
viewModel.init(preferences, destination.itemId, null, true)
}
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
@ -138,6 +141,7 @@ fun SeriesDetails(
series = item,
seasons = seasons,
trailers = trailers,
extras = extras,
people = people,
similar = similar,
played = played,
@ -182,6 +186,9 @@ fun SeriesDetails(
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination)
},
moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) },
@ -253,7 +260,8 @@ private const val HEADER_ROW = 0
private const val SEASONS_ROW = HEADER_ROW + 1
private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val SIMILAR_ROW = TRAILER_ROW + 1
private const val EXTRAS_ROW = TRAILER_ROW + 1
private const val SIMILAR_ROW = EXTRAS_ROW + 1
@Composable
fun SeriesDetailsContent(
@ -262,6 +270,7 @@ fun SeriesDetailsContent(
seasons: List<BaseItem>,
similar: List<BaseItem>,
trailers: List<Trailer>,
extras: List<ExtrasItem>,
people: List<Person>,
played: Boolean,
favorite: Boolean,
@ -273,6 +282,7 @@ fun SeriesDetailsContent(
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions,
modifier: Modifier = Modifier,
) {
@ -478,6 +488,22 @@ fun SeriesDetailsContent(
)
}
}
if (extras.isNotEmpty()) {
item {
ExtrasRow(
extras = extras,
onClickItem = { index, item ->
position = EXTRAS_ROW
onClickExtra.invoke(index, item)
},
onLongClickItem = { _, _ -> },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[EXTRAS_ROW]),
)
}
}
if (similar.isNotEmpty()) {
item {
ItemRow(

View file

@ -92,8 +92,8 @@ fun SeriesOverview(
viewModel.init(
preferences,
destination.itemId,
destination.item,
initialSeasonEpisode,
false,
)
initialLoadDone = true
}

View file

@ -5,6 +5,7 @@ import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
@ -13,6 +14,7 @@ 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.UserPreferences
import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
@ -65,21 +67,24 @@ class SeriesViewModel
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
private val extrasService: ExtrasService,
) : ItemViewModel(api) {
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem>>(listOf())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
fun init(
prefs: UserPreferences,
itemId: UUID,
potential: BaseItem?,
seasonEpisodeIds: SeasonEpisodeIds?,
loadAdditionalDetails: Boolean,
) {
this.seriesId = itemId
this.prefs = prefs
@ -115,30 +120,36 @@ class SeriesViewModel
episodes.value = episodeInfo
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = trailers
}
}
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people)
}
if (!similar.isInitialized) {
if (loadAdditionalDetails) {
viewModelScope.launchIO {
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.from(it, api, true) }
this@SeriesViewModel.similar.setValueOnMain(similar)
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = trailers
}
}
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people)
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(item.id)
this@SeriesViewModel.extras.setValueOnMain(extras)
}
if (!similar.isInitialized) {
viewModelScope.launchIO {
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser.value?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.from(it, api, true) }
this@SeriesViewModel.similar.setValueOnMain(similar)
}
}
}
}

View file

@ -2,6 +2,7 @@
package com.github.damontecres.wholphin.ui.nav
import androidx.annotation.StringRes
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -90,6 +91,13 @@ sealed class Destination(
val recursive: Boolean,
) : Destination(false)
@Serializable
data class ItemGrid(
val title: String?,
@param:StringRes val titleRes: Int?,
val itemIds: List<UUID>,
) : Destination(false)
@Serializable
data object Favorites : Destination(false)

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid
import com.github.damontecres.wholphin.ui.components.LicenseInfo
import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
@ -211,6 +212,12 @@ fun DestinationContent(
modifier = modifier,
)
is Destination.ItemGrid ->
ItemGrid(
destination,
modifier,
)
Destination.Favorites ->
FavoritesPage(
preferences = preferences,