Updates to series detail page

This commit is contained in:
Damontecres 2025-10-07 14:46:37 -04:00
parent 993798d14b
commit 8cda98d6aa
No known key found for this signature in database
10 changed files with 243 additions and 31 deletions

View file

@ -127,6 +127,7 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.preference.ktx)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)

View file

@ -59,7 +59,7 @@ fun ItemCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cardWidth: Dp? = null,
cardHeight: Dp = 200.dp * .85f,
cardHeight: Dp? = 200.dp * .85f,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val hideOverlayDelay = 750L
@ -112,7 +112,7 @@ fun ItemCard(
modifier =
Modifier
.fillMaxWidth()
.height(cardHeight),
.ifElse(cardHeight != null, { Modifier.height(cardHeight!!) }),
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),

View file

@ -16,7 +16,7 @@ import com.github.damontecres.dolphin.ui.ifElse
fun NullCard(
modifier: Modifier = Modifier,
cardWidth: Dp? = null,
cardHeight: Dp = 200.dp * .75f,
cardHeight: Dp? = 200.dp * .75f,
interactionSource: MutableInteractionSource? = null,
) {
Card(
@ -27,7 +27,7 @@ fun NullCard(
Column(
modifier =
Modifier
.height(cardHeight)
.ifElse(cardHeight != null, { Modifier.height(cardHeight!!) })
.ifElse(cardWidth != null, { Modifier.width(cardWidth!!) }),
) {
Text(

View file

@ -9,12 +9,16 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.ui.ifElse
@Composable
fun PersonRow(
@ -23,6 +27,7 @@ fun PersonRow(
modifier: Modifier = Modifier,
onLongClick: ((Person) -> Unit)? = null,
) {
val firstFocus = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@ -40,14 +45,14 @@ fun PersonRow(
Modifier
.padding(start = 16.dp)
.fillMaxWidth()
.focusRestorer(),
.focusRestorer(firstFocus),
) {
itemsIndexed(people) { index, item ->
PersonCard(
item = item,
onClick = { onClick.invoke(item) },
onLongClick = { onLongClick?.invoke(item) },
modifier = Modifier,
modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)),
)
}
}

View file

@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@ -38,16 +40,19 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.tv.material3.Button
import androidx.tv.material3.Icon
import androidx.tv.material3.ListItem
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.util.ExceptionHandler
import kotlinx.coroutines.delay
@ -282,6 +287,7 @@ fun ScrollableDialog(
fun BasicDialog(
onDismissRequest: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp,
content: @Composable () -> Unit,
) {
Dialog(
@ -292,7 +298,7 @@ fun BasicDialog(
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation),
shape = RoundedCornerShape(8.dp),
),
) {
@ -300,3 +306,75 @@ fun BasicDialog(
}
}
}
@Composable
fun ConfirmDialog(
title: String,
body: String?,
onCancel: () -> Unit,
onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 3.dp,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
},
)
@Composable
fun ConfirmDialogContent(
title: String,
body: String?,
onCancel: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = modifier,
) {
item {
Text(
text = title,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillParentMaxWidth(),
)
}
body?.let {
item {
Text(
text = body,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
item {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth(),
) {
Button(
onClick = onCancel,
) {
Text(
text = stringResource(R.string.cancel),
)
}
Button(
onClick = onConfirm,
) {
Text(
text = stringResource(R.string.confirm),
)
}
}
}
}
}

View file

@ -319,6 +319,7 @@ fun CardGrid(
item = item,
onClick = { if (item != null) itemOnClick.invoke(item) },
onLongClick = { if (item != null) longClicker.invoke(item) },
cardHeight = null,
)
}
}

View file

@ -8,18 +8,26 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
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.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -36,16 +44,22 @@ 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.R
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.ConfirmDialog
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
import com.github.damontecres.dolphin.ui.components.ErrorMessage
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
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.data.ItemDetailsDialog
import com.github.damontecres.dolphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.letNotEmpty
import com.github.damontecres.dolphin.ui.nav.Destination
@ -56,6 +70,7 @@ 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
import kotlin.time.Duration
@Composable
fun SeriesDetails(
@ -74,24 +89,58 @@ fun SeriesDetails(
val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty())
val people by viewModel.people.observeAsState(listOf())
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
when (val state = loading) {
is LoadingState.Error -> ErrorMessage(state)
LoadingState.Loading -> LoadingPage()
LoadingState.Success -> {
item?.let { item ->
val played = item.data.userData?.played ?: false
SeriesDetailsContent(
preferences = preferences,
navigationManager = navigationManager,
series = item,
seasons = seasons,
people = people,
played = played,
modifier = modifier,
overviewOnClick = {}, // TODO
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = item.name ?: "Unknown",
overview = item.data.overview,
files = listOf(),
)
},
playOnClick = { viewModel.playNextUp(navigationManager) },
watchOnClick = { showWatchConfirmation = true },
)
if (showWatchConfirmation) {
ConfirmDialog(
title = item.name ?: "",
body = if (played) "Mark entire series as unplayed?" else "Mark entire series as played?",
onCancel = {
showWatchConfirmation = false
},
onConfirm = {
viewModel.setWatchedSeries(!played)
showWatchConfirmation = false
},
)
}
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
onDismissRequest = { overviewDialog = null },
modifier = Modifier,
)
}
}
@Composable
fun SeriesDetailsContent(
@ -100,18 +149,16 @@ fun SeriesDetailsContent(
series: BaseItem,
seasons: ItemListAndMapping,
people: List<Person>,
played: Boolean,
overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val dto = series.data
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val seasonsFocusRequester = remember { FocusRequester() }
if (seasons.items.isNotEmpty()) {
LaunchedEffect(Unit) {
seasonsFocusRequester.tryRequestFocus()
}
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
Box(
modifier = modifier,
@ -153,14 +200,23 @@ fun SeriesDetailsContent(
.fillMaxSize(),
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
item {
SeriesDetailsHeader(
series = series,
played = played,
overviewOnClick = overviewOnClick,
modifier = Modifier.fillMaxWidth(.7f),
playOnClick = playOnClick,
watchOnClick = watchOnClick,
modifier =
Modifier
.fillMaxWidth(.6f)
.bringIntoViewRequester(bringIntoViewRequester)
.focusRequester(focusRequester)
.padding(bottom = 80.dp),
)
}
item {
@ -169,10 +225,16 @@ fun SeriesDetailsContent(
items = seasons.items,
onClickItem = { navigationManager.navigateTo(it.destination()) },
onLongClickItem = { },
cardOnFocus = { isFocused, index ->
// if (isFocused) {
// scope.launch(ExceptionHandler()) {
// bringIntoViewRequester.bringIntoView()
// }
// }
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(seasonsFocusRequester),
.fillMaxWidth(),
)
}
if (people.isNotEmpty()) {
@ -193,7 +255,10 @@ fun SeriesDetailsContent(
@Composable
fun SeriesDetailsHeader(
series: BaseItem,
played: Boolean,
overviewOnClick: () -> Unit,
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -205,6 +270,7 @@ fun SeriesDetailsHeader(
?.ticks
?.roundMinutes
?.let { add(it.toString()) }
dto.officialRating?.let(::add)
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -212,12 +278,12 @@ fun SeriesDetailsHeader(
) {
Text(
text = series.name ?: "Unknown",
style = MaterialTheme.typography.displayMedium,
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.fillMaxWidth(),
)
DotSeparatedRow(
texts = details,
textStyle = MaterialTheme.typography.headlineSmall,
textStyle = MaterialTheme.typography.titleMedium,
)
dto.genres?.letNotEmpty {
@ -237,7 +303,7 @@ fun SeriesDetailsHeader(
enabled = false,
precision = StarRatingPrecision.HALF,
playSoundOnFocus = true,
modifier = Modifier.height(32.dp),
modifier = Modifier.height(24.dp),
)
}
}
@ -278,5 +344,23 @@ fun SeriesDetailsHeader(
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(start = 16.dp),
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { playOnClick.invoke() },
modifier = Modifier,
)
ExpandableFaButton(
title = if (played) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (played) R.string.fa_eye else R.string.fa_eye_slash,
onClick = watchOnClick,
modifier = Modifier,
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.dolphin.ui.detail
import android.content.Context
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
@ -17,6 +18,8 @@ import com.github.damontecres.dolphin.hilt.AuthOkHttpClient
import com.github.damontecres.dolphin.preferences.ThemeSongVolume
import com.github.damontecres.dolphin.preferences.UserPreferences
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.util.ApiRequestPager
import com.github.damontecres.dolphin.util.ExceptionHandler
import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
@ -33,6 +36,7 @@ import okhttp3.OkHttpClient
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
@ -55,6 +59,7 @@ class SeriesViewModel
) : ItemViewModel<Video>(api) {
private var player: Player? = null
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
@ -68,6 +73,7 @@ class SeriesViewModel
episode: Int?,
) {
this.seriesId = itemId
this.prefs = prefs
viewModelScope.launch(
LoadingExceptionHandler(
loading,
@ -241,13 +247,24 @@ class SeriesViewModel
refreshEpisode(itemId, listIndex)
}
fun setWatchedSeries(played: Boolean) =
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
if (played) {
api.playStateApi.markPlayedItem(seriesId)
} else {
api.playStateApi.markUnplayedItem(seriesId)
}
init(prefs, seriesId, null, null, null)
}
fun refreshEpisode(
itemId: UUID,
listIndex: Int,
) = viewModelScope.launch(ExceptionHandler()) {
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val base = api.userLibraryApi.getItem(itemId).content
val item = BaseItem.Companion.from(base, api)
val eps = episodes.value!!
withContext(Dispatchers.Main) {
episodes.value =
eps.copy(
items =
@ -258,6 +275,27 @@ class SeriesViewModel
}
}
fun playNextUp(nav: NavigationManager) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val result by api.tvShowsApi.getNextUp(seriesId = seriesId)
val nextUp =
result.items.firstOrNull() ?: api.tvShowsApi
.getEpisodes(
seriesId,
limit = 1,
).content.items
.firstOrNull()
if (nextUp != null) {
nav.navigateTo(Destination.Playback(nextUp.id, 0L))
} else {
Toast
.makeText(context, "Could not find an episode to play", Toast.LENGTH_SHORT)
.show()
}
}
}
}
data class ItemListAndMapping(
val items: List<BaseItem?>,
val numberToIndex: Map<Int, Int>,

View file

@ -56,5 +56,8 @@
<string name="sort_by_date_played">Date Played</string>
<string name="sort_by_date_released">Date Released</string>
<string name="skip_back_on_resume_preference">Skip back when resuming playback</string>
<string name="pref_key_update_last_check_threshold">preference.update.threshold</string>
<string name="pref_key_update_last_check">preference.update.lastTimestamp</string>
<string name="confirm">Confirm</string>
</resources>

View file

@ -27,6 +27,7 @@ protobuf-javalite = "4.32.1"
hilt = "2.57.2"
room = "2.8.1"
material3 = "1.4.0"
preferenceKtx = "1.2.1"
[libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
@ -85,6 +86,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref =
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }