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 import com.github.damontecres.wholphin.util.FocusPair
@Composable @Composable
fun ItemRow( fun <T> ItemRow(
title: String, title: String,
items: List<BaseItem?>, items: List<T?>,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, T) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, T) -> Unit,
cardContent: @Composable ( cardContent: @Composable (
index: Int, index: Int,
item: BaseItem?, item: T?,
modifier: Modifier, modifier: Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> 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.input.key.type
import androidx.compose.ui.platform.LocalContext 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.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
@ -93,9 +94,10 @@ fun CardGrid(
modifier = mod, modifier = mod,
) )
}, },
columns: Int = 6,
spacing: Dp = 16.dp,
) { ) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val columns = 6
val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f) val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f)
val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow) val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow)
@ -232,8 +234,8 @@ fun CardGrid(
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(spacing),
state = gridState, state = gridState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
modifier = modifier =

View file

@ -45,6 +45,7 @@ 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
import com.github.damontecres.wholphin.data.ExtrasItem
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.LocalTrailer 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.AspectRatios
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.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow 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
@ -112,6 +114,7 @@ fun MovieDetails(
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 trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.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)
@ -159,6 +162,7 @@ fun MovieDetails(
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
people = people, people = people,
chapters = chapters, chapters = chapters,
extras = extras,
trailers = trailers, trailers = trailers,
similar = similar, similar = similar,
onClickItem = { index, item -> onClickItem = { index, item ->
@ -282,6 +286,9 @@ fun MovieDetails(
trailerOnClick = { trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo) TrailerService.onClick(context, it, viewModel::navigateTo)
}, },
onClickExtra = { index, extra ->
viewModel.navigateTo(extra.destination)
},
modifier = modifier, modifier = modifier,
) )
} }
@ -340,7 +347,8 @@ private const val HEADER_ROW = 0
private const val PEOPLE_ROW = HEADER_ROW + 1 private const val PEOPLE_ROW = HEADER_ROW + 1
private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val CHAPTER_ROW = TRAILER_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 @Composable
fun MovieDetailsContent( fun MovieDetailsContent(
@ -350,6 +358,7 @@ fun MovieDetailsContent(
people: List<Person>, people: List<Person>,
chapters: List<Chapter>, chapters: List<Chapter>,
trailers: List<Trailer>, trailers: List<Trailer>,
extras: List<ExtrasItem>,
similar: List<BaseItem>, similar: List<BaseItem>,
playOnClick: (Duration) -> Unit, playOnClick: (Duration) -> Unit,
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
@ -361,8 +370,10 @@ fun MovieDetailsContent(
onClickPerson: (Person) -> Unit, onClickPerson: (Person) -> Unit,
onLongClickPerson: (Int, Person) -> Unit, onLongClickPerson: (Int, Person) -> Unit,
onLongClickSimilar: (Int, BaseItem) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var position by rememberInt(0) var position by rememberInt(0)
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } 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()) { if (similar.isNotEmpty()) {
item { item {
ItemRow( ItemRow(

View file

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

View file

@ -44,12 +44,14 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.ExtrasItem
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.data.model.Trailer
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards 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.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
@ -99,13 +101,14 @@ fun SeriesDetails(
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(Unit) { 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 loading by viewModel.loading.observeAsState(LoadingState.Loading)
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 trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.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())
@ -138,6 +141,7 @@ fun SeriesDetails(
series = item, series = item,
seasons = seasons, seasons = seasons,
trailers = trailers, trailers = trailers,
extras = extras,
people = people, people = people,
similar = similar, similar = similar,
played = played, played = played,
@ -182,6 +186,9 @@ fun SeriesDetails(
trailerOnClick = { trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo) TrailerService.onClick(context, it, viewModel::navigateTo)
}, },
onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination)
},
moreActions = moreActions =
MoreDialogActions( MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) }, 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 SEASONS_ROW = HEADER_ROW + 1
private const val PEOPLE_ROW = SEASONS_ROW + 1 private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val TRAILER_ROW = PEOPLE_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 @Composable
fun SeriesDetailsContent( fun SeriesDetailsContent(
@ -262,6 +270,7 @@ fun SeriesDetailsContent(
seasons: List<BaseItem>, seasons: List<BaseItem>,
similar: List<BaseItem>, similar: List<BaseItem>,
trailers: List<Trailer>, trailers: List<Trailer>,
extras: List<ExtrasItem>,
people: List<Person>, people: List<Person>,
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
@ -273,6 +282,7 @@ fun SeriesDetailsContent(
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions, moreActions: MoreDialogActions,
modifier: Modifier = Modifier, 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()) { if (similar.isNotEmpty()) {
item { item {
ItemRow( ItemRow(

View file

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

View file

@ -5,6 +5,7 @@ import android.widget.Toast
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams 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.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
@ -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.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.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.PeopleFavorites
@ -65,21 +67,24 @@ class SeriesViewModel
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites, private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService, private val trailerService: TrailerService,
private val extrasService: ExtrasService,
) : 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 trailers = MutableLiveData<List<Trailer>>(listOf())
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
fun init( fun init(
prefs: UserPreferences, prefs: UserPreferences,
itemId: UUID, itemId: UUID,
potential: BaseItem?,
seasonEpisodeIds: SeasonEpisodeIds?, seasonEpisodeIds: SeasonEpisodeIds?,
loadAdditionalDetails: Boolean,
) { ) {
this.seriesId = itemId this.seriesId = itemId
this.prefs = prefs this.prefs = prefs
@ -115,30 +120,36 @@ class SeriesViewModel
episodes.value = episodeInfo episodes.value = episodeInfo
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
viewModelScope.launchIO { if (loadAdditionalDetails) {
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) {
viewModelScope.launchIO { viewModelScope.launchIO {
val similar = val trailers = trailerService.getTrailers(item)
api.libraryApi withContext(Dispatchers.Main) {
.getSimilarItems( this@SeriesViewModel.trailers.value = trailers
GetSimilarItemsRequest( }
userId = serverRepository.currentUser.value?.id, }
itemId = itemId, viewModelScope.launchIO {
fields = SlimItemFields, val people = peopleFavorites.getPeopleFor(item)
limit = 25, this@SeriesViewModel.people.setValueOnMain(people)
), }
).content.items viewModelScope.launchIO {
.map { BaseItem.from(it, api, true) } val extras = extrasService.getExtras(item.id)
this@SeriesViewModel.similar.setValueOnMain(similar) 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 package com.github.damontecres.wholphin.ui.nav
import androidx.annotation.StringRes
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -90,6 +91,13 @@ sealed class Destination(
val recursive: Boolean, val recursive: Boolean,
) : Destination(false) ) : Destination(false)
@Serializable
data class ItemGrid(
val title: String?,
@param:StringRes val titleRes: Int?,
val itemIds: List<UUID>,
) : Destination(false)
@Serializable @Serializable
data object Favorites : Destination(false) data object Favorites : Destination(false)

View file

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

View file

@ -153,6 +153,80 @@
<string name="official_rating">Parental Rating</string> <string name="official_rating">Parental Rating</string>
<string name="runtime_sort">Runtime</string> <string name="runtime_sort">Runtime</string>
<string name="extras">Extras</string>
<string name="other_extras">Other</string>
<string name="behind_the_scenes">Behind the Scenes</string>
<string name="theme_songs">Theme Songs</string>
<string name="theme_videos">Theme Videos</string>
<string name="clips">Clips</string>
<string name="deleted_scenes">Deleted Scenes</string>
<string name="interviews">Interviews</string>
<string name="scenes">Scenes</string>
<string name="samples">Samples</string>
<string name="featurettes">Featurettes</string>
<string name="shorts">Shorts</string>
<plurals name="trailers">
<item quantity="zero">Trailers</item>
<item quantity="one">Trailer</item>
<item quantity="other">Trailers</item>
</plurals>
<plurals name="other_extras">
<item quantity="zero">Other</item>
<item quantity="one">Other</item>
<item quantity="other">Other</item>
</plurals>
<plurals name="behind_the_scenes">
<item quantity="zero">Behind the scenes</item>
<item quantity="one">Behind the scenes</item>
<item quantity="other">Behind the scenes</item>
</plurals>
<plurals name="theme_songs">
<item quantity="zero">Theme songs</item>
<item quantity="one">Theme song</item>
<item quantity="other">Theme songs</item>
</plurals>
<plurals name="theme_videos">
<item quantity="zero">Theme videos</item>
<item quantity="one">Theme video</item>
<item quantity="other">Theme videos</item>
</plurals>
<plurals name="clips">
<item quantity="zero">Clips</item>
<item quantity="one">Clip</item>
<item quantity="other">Clips</item>
</plurals>
<plurals name="deleted_scenes">
<item quantity="zero">Deleted scenes</item>
<item quantity="one">Deleted scene</item>
<item quantity="other">Deleted scenes</item>
</plurals>
<plurals name="interviews">
<item quantity="zero">Interviews</item>
<item quantity="one">Interview</item>
<item quantity="other">Interviews</item>
</plurals>
<plurals name="scenes">
<item quantity="zero">Scenes</item>
<item quantity="one">Scene</item>
<item quantity="other">Scenes</item>
</plurals>
<plurals name="samples">
<item quantity="zero">Samples</item>
<item quantity="one">Sample</item>
<item quantity="other">Samples</item>
</plurals>
<plurals name="featurettes">
<item quantity="zero">Featurettes</item>
<item quantity="one">Featurette</item>
<item quantity="other">Featurettes</item>
</plurals>
<plurals name="shorts">
<item quantity="zero">Shorts</item>
<item quantity="one">Short</item>
<item quantity="other">Shorts</item>
</plurals>
<plurals name="downloads"> <plurals name="downloads">
<item quantity="zero">%s downloads</item> <item quantity="zero">%s downloads</item>
<item quantity="one">%s download</item> <item quantity="one">%s download</item>