mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
2f56a3a114
commit
93d82a263d
14 changed files with 542 additions and 36 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ fun SeriesOverview(
|
|||
viewModel.init(
|
||||
preferences,
|
||||
destination.itemId,
|
||||
destination.item,
|
||||
initialSeasonEpisode,
|
||||
false,
|
||||
)
|
||||
initialLoadDone = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +120,7 @@ class SeriesViewModel
|
|||
episodes.value = episodeInfo
|
||||
loading.value = LoadingState.Success
|
||||
}
|
||||
if (loadAdditionalDetails) {
|
||||
viewModelScope.launchIO {
|
||||
val trailers = trailerService.getTrailers(item)
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -125,6 +131,10 @@ class SeriesViewModel
|
|||
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 =
|
||||
|
|
@ -143,6 +153,7 @@ class SeriesViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the series has a theme song & app settings allow, play it
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,80 @@
|
|||
<string name="official_rating">Parental Rating</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">
|
||||
<item quantity="zero">%s downloads</item>
|
||||
<item quantity="one">%s download</item>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue