diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt new file mode 100644 index 00000000..669c56fb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ExtrasItem.kt @@ -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, + ) : 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 + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt new file mode 100644 index 00000000..e952177b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ExtrasService.kt @@ -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 { + 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 + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt new file mode 100644 index 00000000..fcfba675 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -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, + 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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index e629fb97..e52fdd0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -26,14 +26,14 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.util.FocusPair @Composable -fun ItemRow( +fun ItemRow( title: String, - items: List, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, + items: List, + onClickItem: (Int, T) -> Unit, + onLongClickItem: (Int, T) -> Unit, cardContent: @Composable ( index: Int, - item: BaseItem?, + item: T?, modifier: Modifier, onClick: () -> Unit, onLongClick: () -> Unit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt new file mode 100644 index 00000000..a73d9106 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -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.Loading) + val items = MutableLiveData>(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( + 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(), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 4eb40eec..3f6f5a40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -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 = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 346ef4a6..28d84003 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -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, chapters: List, trailers: List, + extras: List, similar: List, 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 4318f2a7..17a68afc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -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>(listOf()) val people = MutableLiveData>(listOf()) val chapters = MutableLiveData>(listOf()) + val extras = MutableLiveData>(listOf()) val similar = MutableLiveData>() val chosenStreams = MutableLiveData(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) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 25806ce1..9cb0598f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -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, similar: List, trailers: List, + extras: List, people: List, 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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 8fc9ff2d..ad5befee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -92,8 +92,8 @@ fun SeriesOverview( viewModel.init( preferences, destination.itemId, - destination.item, initialSeasonEpisode, + false, ) initialLoadDone = true } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 16335b2d..89cd5937 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -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.Loading) val seasons = MutableLiveData>(listOf()) val episodes = MutableLiveData(EpisodeList.Loading) + val trailers = MutableLiveData>(listOf()) + val extras = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) val similar = MutableLiveData>() 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) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index ff2ffdde..e72e5339 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -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, + ) : Destination(false) + @Serializable data object Favorites : Destination(false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 8ba5f51a..ebd8ec2d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93d5b3c6..1f049be9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -153,6 +153,80 @@ Parental Rating Runtime + Extras + Other + Behind the Scenes + Theme Songs + Theme Videos + Clips + Deleted Scenes + Interviews + Scenes + Samples + Featurettes + Shorts + + + Trailers + Trailer + Trailers + + + Other + Other + Other + + + Behind the scenes + Behind the scenes + Behind the scenes + + + Theme songs + Theme song + Theme songs + + + Theme videos + Theme video + Theme videos + + + Clips + Clip + Clips + + + Deleted scenes + Deleted scene + Deleted scenes + + + Interviews + Interview + Interviews + + + Scenes + Scene + Scenes + + + Samples + Sample + Samples + + + Featurettes + Featurette + Featurettes + + + Shorts + Short + Shorts + + %s downloads %s download