diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 24966b6c..81fd846a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt index ec78e98a..e47f80aa 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt @@ -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), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt index cd31fefc..3d6ea537 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt index 0cd5b7d6..967a3026 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonRow.kt @@ -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)), ) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt index 10f2f76b..d1ba454a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt @@ -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), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt index 0c39b157..3caee1a0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt @@ -319,6 +319,7 @@ fun CardGrid( item = item, onClick = { if (item != null) itemOnClick.invoke(item) }, onLongClick = { if (item != null) longClicker.invoke(item) }, + cardHeight = null, ) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 3305327a..1e4c1d12 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -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,23 +89,57 @@ fun SeriesDetails( val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) val people by viewModel.people.observeAsState(listOf()) + var overviewDialog by remember { mutableStateOf(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 @@ -100,18 +149,16 @@ fun SeriesDetailsContent( series: BaseItem, seasons: ItemListAndMapping, people: List, + 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, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt index 95d52782..c25c3c76 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt @@ -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