mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Use series details page
This commit is contained in:
parent
887dd7001c
commit
833e1d2ea1
11 changed files with 283 additions and 29 deletions
|
|
@ -25,14 +25,14 @@ data class BaseItem(
|
|||
@Transient
|
||||
val indexNumber = data.indexNumber
|
||||
|
||||
fun destination(): Destination.MediaItem {
|
||||
fun destination(): Destination {
|
||||
val result =
|
||||
// Redirect episodes & seasons to their series if possible
|
||||
when (type) {
|
||||
BaseItemKind.EPISODE -> {
|
||||
data.indexNumber?.let { episode ->
|
||||
data.parentIndexNumber?.let { season ->
|
||||
Destination.MediaItem(
|
||||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
BaseItemKind.SERIES,
|
||||
this,
|
||||
|
|
@ -43,8 +43,8 @@ data class BaseItem(
|
|||
}
|
||||
|
||||
BaseItemKind.SEASON ->
|
||||
data.parentIndexNumber?.let { season ->
|
||||
Destination.MediaItem(
|
||||
data.indexNumber?.let { season ->
|
||||
Destination.SeriesOverview(
|
||||
data.seriesId!!,
|
||||
BaseItemKind.SERIES,
|
||||
this,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ fun CardGrid(
|
|||
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
|
||||
) {
|
||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||
val columns = 5
|
||||
val columns = 6
|
||||
|
||||
val gridState = rememberLazyGridState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
|
|||
|
|
@ -30,15 +30,17 @@ abstract class ItemViewModel<T : DolphinModel>(
|
|||
potential: BaseItem?,
|
||||
): BaseItem? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val fetchedItem =
|
||||
when {
|
||||
item.value == null && potential?.id == itemId -> potential
|
||||
item.value?.id == itemId -> item.value
|
||||
else -> {
|
||||
val it = api.userLibraryApi.getItem(itemId).content
|
||||
BaseItem.from(it, api)
|
||||
}
|
||||
}
|
||||
// val fetchedItem =
|
||||
// when {
|
||||
// item.value == null && potential?.id == itemId -> potential
|
||||
// item.value?.id == itemId -> item.value
|
||||
// else -> {
|
||||
// val it = api.userLibraryApi.getItem(itemId).content
|
||||
// BaseItem.from(it, api)
|
||||
// }
|
||||
// }
|
||||
val it = api.userLibraryApi.getItem(itemId).content
|
||||
val fetchedItem = BaseItem.from(it, api)
|
||||
return@withContext fetchedItem?.let {
|
||||
val modelInstance = convertModel(fetchedItem.data, api)
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,61 @@
|
|||
package com.github.damontecres.dolphin.ui.detail
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.data.model.Person
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.dolphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||
import com.github.damontecres.dolphin.ui.components.ErrorMessage
|
||||
import com.github.damontecres.dolphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.dolphin.ui.components.StarRating
|
||||
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.dolphin.ui.letNotEmpty
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.dolphin.ui.playOnClickSound
|
||||
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
||||
import com.github.damontecres.dolphin.ui.roundMinutes
|
||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.util.LoadingState
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
@Composable
|
||||
fun SeriesDetails(
|
||||
|
|
@ -28,25 +68,119 @@ fun SeriesDetails(
|
|||
LaunchedEffect(Unit) {
|
||||
viewModel.init(destination.itemId, destination.item, null, null)
|
||||
}
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
|
||||
val item by viewModel.item.observeAsState()
|
||||
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> ErrorMessage(state)
|
||||
LoadingState.Loading -> LoadingPage()
|
||||
LoadingState.Success -> {
|
||||
item?.let { item ->
|
||||
LazyColumn(modifier = modifier) {
|
||||
SeriesDetailsContent(
|
||||
preferences = preferences,
|
||||
navigationManager = navigationManager,
|
||||
series = item,
|
||||
seasons = seasons,
|
||||
people = people,
|
||||
modifier = modifier,
|
||||
overviewOnClick = {}, // TODO
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SeriesDetailsContent(
|
||||
preferences: UserPreferences,
|
||||
navigationManager: NavigationManager,
|
||||
series: BaseItem,
|
||||
seasons: ItemListAndMapping,
|
||||
people: List<Person>,
|
||||
overviewOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dto = series.data
|
||||
|
||||
val seasonsFocusRequester = remember { FocusRequester() }
|
||||
if (seasons.items.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) {
|
||||
seasonsFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
) {
|
||||
if (series.backdropImageUrl.isNotNullOrBlank()) {
|
||||
val gradientColor = MaterialTheme.colorScheme.background
|
||||
AsyncImage(
|
||||
model = series.backdropImageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.TopEnd,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight(.75f)
|
||||
.alpha(.5f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, gradientColor),
|
||||
startY = size.height * .5f,
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(Color.Transparent, gradientColor),
|
||||
endX = 0f,
|
||||
startX = size.width * .75f,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
SeriesDetailsHeader(
|
||||
series = series,
|
||||
overviewOnClick = overviewOnClick,
|
||||
modifier = Modifier.fillMaxWidth(.7f),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ItemRow(
|
||||
title = "Seasons",
|
||||
items = seasons.items,
|
||||
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
||||
onLongClickItem = { },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(seasonsFocusRequester),
|
||||
)
|
||||
}
|
||||
if (people.isNotEmpty()) {
|
||||
item {
|
||||
Text(text = item.name ?: "Unknown")
|
||||
}
|
||||
item {
|
||||
ItemRow(
|
||||
title = "Seasons",
|
||||
items = seasons.items,
|
||||
onClickItem = { navigationManager.navigateTo(it.destination()) },
|
||||
onLongClickItem = { },
|
||||
PersonRow(
|
||||
people = people,
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
|
@ -55,3 +189,94 @@ fun SeriesDetails(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SeriesDetailsHeader(
|
||||
series: BaseItem,
|
||||
overviewOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dto = series.data
|
||||
val details =
|
||||
buildList {
|
||||
dto.productionYear?.let { add(it.toString()) }
|
||||
dto.runTimeTicks
|
||||
?.ticks
|
||||
?.roundMinutes
|
||||
?.let { add(it.toString()) }
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = series.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
DotSeparatedRow(
|
||||
texts = details,
|
||||
textStyle = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
|
||||
dto.genres?.letNotEmpty {
|
||||
Text(
|
||||
text = it.joinToString(", "),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
||||
dto.communityRating?.let {
|
||||
if (it >= 0f) {
|
||||
StarRating(
|
||||
rating100 = (it * 10).toInt(),
|
||||
onRatingChange = {},
|
||||
enabled = false,
|
||||
precision = StarRatingPrecision.HALF,
|
||||
playSoundOnFocus = true,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dto.overview?.let { overview ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
val bgColor =
|
||||
if (isFocused) {
|
||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
||||
.playSoundOnFocus(true)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current,
|
||||
) {
|
||||
playOnClickSound(context)
|
||||
overviewOnClick.invoke()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = overview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.height(60.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.data.model.Person
|
||||
import com.github.damontecres.dolphin.data.model.Video
|
||||
import com.github.damontecres.dolphin.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.dolphin.ui.letNotEmpty
|
||||
import com.github.damontecres.dolphin.util.ApiRequestPager
|
||||
import com.github.damontecres.dolphin.util.ExceptionHandler
|
||||
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
|
||||
|
|
@ -54,6 +56,7 @@ class SeriesViewModel
|
|||
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
|
||||
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
||||
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
|
||||
val people = MutableLiveData<List<Person>>(listOf())
|
||||
|
||||
fun init(
|
||||
itemId: UUID,
|
||||
|
|
@ -80,6 +83,11 @@ class SeriesViewModel
|
|||
seasons.value = seasonsInfo
|
||||
episodes.value = episodeInfo
|
||||
loading.value = LoadingState.Success
|
||||
people.value =
|
||||
item.data.people
|
||||
?.letNotEmpty { people ->
|
||||
people.map { Person.fromDto(it, api) }
|
||||
}.orEmpty()
|
||||
}
|
||||
maybePlayThemeSong()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ data class SeriesOverviewPosition(
|
|||
fun SeriesOverview(
|
||||
preferences: UserPreferences,
|
||||
navigationManager: NavigationManager,
|
||||
destination: Destination.MediaItem,
|
||||
destination: Destination.SeriesOverview,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel(),
|
||||
initialSeasonEpisode: SeasonEpisode? = null,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class MainViewModel
|
|||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem.Companion.from(it, api, true) }
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
HomeRow(
|
||||
section = section,
|
||||
items = latest,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.dolphin.data.JellyfinServer
|
|||
import com.github.damontecres.dolphin.data.JellyfinUser
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import timber.log.Timber
|
||||
|
||||
@Composable
|
||||
fun ApplicationContent(
|
||||
|
|
@ -34,6 +35,7 @@ fun ApplicationContent(
|
|||
entryProvider = { key ->
|
||||
key as Destination
|
||||
val contentKey = "${key}_${server.id}_${user.id}"
|
||||
Timber.d("Navigate: %s", key)
|
||||
NavEntry(key, contentKey = contentKey) {
|
||||
if (key.fullScreen) {
|
||||
DestinationContent(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ sealed class Destination(
|
|||
@Serializable
|
||||
data object Search : Destination()
|
||||
|
||||
@Serializable
|
||||
data class SeriesOverview(
|
||||
val itemId: UUID,
|
||||
val type: BaseItemKind,
|
||||
@Transient val item: BaseItem? = null,
|
||||
val seasonEpisode: SeasonEpisode? = null,
|
||||
) : Destination()
|
||||
|
||||
@Serializable
|
||||
data class MediaItem(
|
||||
val itemId: UUID,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.github.damontecres.dolphin.ui.components.LicenseInfo
|
|||
import com.github.damontecres.dolphin.ui.detail.CollectionFolderDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.SeasonDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.SeriesDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.VideoDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
|
||||
|
|
@ -56,15 +57,23 @@ fun DestinationContent(
|
|||
modifier,
|
||||
)
|
||||
|
||||
is Destination.SeriesOverview ->
|
||||
SeriesOverview(
|
||||
preferences,
|
||||
navigationManager,
|
||||
destination,
|
||||
modifier,
|
||||
initialSeasonEpisode = destination.seasonEpisode,
|
||||
)
|
||||
|
||||
is Destination.MediaItem ->
|
||||
when (destination.type) {
|
||||
BaseItemKind.SERIES ->
|
||||
SeriesOverview(
|
||||
SeriesDetails(
|
||||
preferences,
|
||||
navigationManager,
|
||||
destination,
|
||||
modifier,
|
||||
initialSeasonEpisode = destination.seasonEpisode,
|
||||
)
|
||||
|
||||
BaseItemKind.SEASON ->
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import java.time.format.DateTimeFormatter
|
|||
|
||||
fun formatDateTime(dateTime: LocalDateTime): String =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val formatter = DateTimeFormatter.ofPattern("MMMM d, yyyy")
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||
formatter.format(dateTime)
|
||||
} else if (dateTime.toString().length >= 10) {
|
||||
dateTime.toString().substring(0, 10)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue