diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt index f05daeb1..d216729c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt @@ -14,6 +14,7 @@ import org.jellyfin.sdk.model.api.ImageType data class BaseItem( val data: BaseItemDto, val imageUrl: String?, + val backdropImageUrl: String? = null, ) { @Transient val id = data.id @@ -57,6 +58,23 @@ data class BaseItem( fun from( dto: BaseItemDto, api: ApiClient, - ) = BaseItem(dto, api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)) + ): BaseItem { + val backdropImageUrl = + if (dto.type == BaseItemKind.EPISODE) { + val seriesId = dto.seriesId + if (seriesId != null) { + api.imageApi.getItemImageUrl(seriesId, ImageType.BACKDROP) + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) + } + } else { + api.imageApi.getItemImageUrl(dto.id, ImageType.BACKDROP) + } + return BaseItem( + dto, + api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), + backdropImageUrl, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt index 6c59019b..c70693c9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt @@ -19,9 +19,14 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalContracts::class) fun CharSequence?.isNotNullOrBlank(): Boolean { @@ -163,3 +168,16 @@ fun playOnClickSound( val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.playSoundEffect(effectType) } + +val Duration.roundMinutes: Duration + get() = (this + 30.seconds).inWholeMinutes.minutes + +val BaseItemDto.timeRemaining: Duration? + get() = + userData?.playbackPositionTicks?.let { + if (it > 0) { + runTimeTicks?.minus(it)?.ticks + } else { + null + } + } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt index f391f5a3..a3a155bb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt @@ -25,6 +25,7 @@ val DefaultItemFields = ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.SEASON_USER_DATA, ItemFields.CHILD_COUNT, + ItemFields.OVERVIEW, ) @Preview( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt index 9f13ecf6..65bf35df 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt @@ -62,7 +62,7 @@ fun BannerCard( .align(Alignment.TopEnd) .padding(4.dp), ) { - if (played) { + if (played && (playPercent <= 0 || playPercent >= 100)) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, 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 d9e3071a..786732be 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 @@ -159,7 +159,7 @@ fun ItemCardImage( fontFamily = FontAwesome, ) } - if (watched) { + if (watched && watchedPercent?.let { it <= 0 || it >= 100 } == true) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = 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 41b11a0f..d0432399 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 @@ -22,6 +22,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -103,6 +105,7 @@ class SeriesViewModel ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, + ItemFields.CUSTOM_RATING, ), ) val pager = ItemPager(api, request, viewModelScope) @@ -111,6 +114,31 @@ class SeriesViewModel episodes.value = pager } } + + fun setWatched( + itemId: UUID, + played: Boolean, + listIndex: Int, + ) = viewModelScope.launch { + if (played) { + api.playStateApi.markPlayedItem(itemId) + } else { + api.playStateApi.markUnplayedItem(itemId) + } + refreshEpisode(itemId, listIndex) + } + + fun refreshEpisode( + itemId: UUID, + listIndex: Int, + ) = viewModelScope.launch { + val base = api.userLibraryApi.getItem(itemId).content + val item = BaseItem.from(base, api) + episodes.value = + episodes.value!!.toMutableList().apply { + this[listIndex] = item + } + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt index 5e9c2370..7b79b88d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape @@ -28,6 +29,8 @@ import com.github.damontecres.dolphin.ui.components.StarRating import com.github.damontecres.dolphin.ui.components.StarRatingPrecision 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.timeRemaining import com.github.damontecres.dolphin.util.formatDateTime import org.jellyfin.sdk.model.extensions.ticks @@ -57,10 +60,14 @@ fun FocusedEpisodeHeader( add("S${dto.parentIndexNumber} E${dto.indexNumber}") } dto.premiereDate?.let { add(formatDateTime(it)) } - dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let { - add(it) - } - dto.seriesStudio?.let { add(it) } + val duration = dto.runTimeTicks?.ticks + duration + ?.roundMinutes + ?.toString() + ?.let { + add(it) + } + dto.timeRemaining?.roundMinutes?.let { add("$it left") } } DotSeparatedRow( texts = details, @@ -71,33 +78,28 @@ fun FocusedEpisodeHeader( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - dto.criticRating?.let { - Text( - text = "Critic: ${dto.criticRating}", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier, - ) + // TODO ratings? + dto.communityRating?.let { + if (it > 0f) { + StarRating( + rating100 = (it * 10).toInt(), + onRatingChange = {}, + enabled = false, + precision = StarRatingPrecision.HALF, + playSoundOnFocus = true, + modifier = Modifier.height(24.dp), + ) + } else { + Spacer(Modifier.height(24.dp)) + } } - StarRating( - rating100 = - dto - .userData - ?.rating - ?.times(100) - ?.toInt() ?: 0, - 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 = .75f) + MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f) } else { Color.Unspecified } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt index e8704f22..a7716cf1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -65,9 +65,15 @@ fun SeriesOverview( if (episodes.isNotEmpty()) { // TODO focus on first episode when changing seasons // firstItemFocusRequester.requestFocus() + episodes.getOrNull(seasonEpisode.episode)?.let { + viewModel.refreshEpisode(it.id, seasonEpisode.episode) + } } } + LaunchedEffect(Unit) { + } + if (series == null) { // TODO Text(text = "Loading...") @@ -109,6 +115,11 @@ fun SeriesOverview( }, watchOnClick = { // TODO toggle watched state + episodes.getOrNull(seasonEpisode.episode)?.let { + val played = it.data.userData?.played ?: false + // TODO map indexes + viewModel.setWatched(it.id, !played, seasonEpisode.episode) + } }, moreOnClick = { // TODO show more actions diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt index 37540a75..57522c0c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Tab +import androidx.tv.material3.TabDefaults import androidx.tv.material3.TabRow import androidx.tv.material3.Text import coil3.compose.AsyncImage @@ -133,13 +134,16 @@ fun SeriesOverviewContent( selectedTabIndex = index onFocus.invoke(SeasonEpisode(index, 0)) }, + colors = + TabDefaults.pillIndicatorTabColors( + // TODO + ), modifier = Modifier .focusRequester(focusRequesters[index]), ) { Text( text = season.name ?: "Season ${season.data.indexNumber}", - style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt index 5c237253..033d4531 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/MainPage.kt @@ -1,27 +1,51 @@ package com.github.damontecres.dolphin.ui.main 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.Spacer +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.lazy.items import androidx.compose.runtime.Composable 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 +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow 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 coil3.compose.AsyncImage import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.cards.ItemRow +import com.github.damontecres.dolphin.ui.components.DotSeparatedRow import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.roundMinutes +import com.github.damontecres.dolphin.ui.timeRemaining +import com.github.damontecres.dolphin.util.formatDateTime import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -32,9 +56,11 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import javax.inject.Inject @@ -159,22 +185,149 @@ fun MainPage( viewModel: MainViewModel = hiltViewModel(), ) { val homeRows by viewModel.homeRows.observeAsState(listOf()) - Column(modifier = modifier) { - // TODO header? - LazyColumn( - verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(16.dp), - ) { - items(homeRows) { row -> - ItemRow( - title = stringResource(row.section.nameRes), - items = row.items, - onClickItem = { - navigationManager.navigateTo(it.destination()) - }, - onLongClickItem = {}, - modifier = Modifier.fillMaxWidth(), - ) + var focusedItem by remember { mutableStateOf(null) } + Box(modifier = modifier) { + if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) { + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = focusedItem?.backdropImageUrl, + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd, + modifier = + Modifier + .fillMaxHeight(.7f) + .fillMaxWidth(.7f) + .alpha(.4f) + .align(Alignment.TopEnd) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = size.height * .33f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + startX = 0f, + endX = size.width * .5f, + ), + ) + }, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + MainPageHeader( + item = focusedItem, + modifier = + Modifier + .fillMaxWidth(.6f) + .fillMaxHeight(.33f) + .padding(16.dp), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 0.dp, + bottom = 48.dp, + ), + modifier = Modifier, + ) { + items(homeRows) { row -> + ItemRow( + title = stringResource(row.section.nameRes), + items = row.items, + onClickItem = { + navigationManager.navigateTo(it.destination()) + }, + cardOnFocus = { isFocused, index -> + focusedItem = row.items.getOrNull(index) + }, + onLongClickItem = {}, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +fun MainPageHeader( + item: BaseItem?, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item?.let { + val dto = item.data + val isEpisode = item.type == BaseItemKind.EPISODE + val title = if (isEpisode) dto.seriesName ?: item.name else item.name + val subtitle = if (isEpisode) dto.name else null + val overview = dto.overview + val details = + buildList { + if (isEpisode) { + add("S${dto.parentIndexNumber} E${dto.indexNumber}") + } + if (isEpisode) { + dto.premiereDate?.let { add(formatDateTime(it)) } + } else { + dto.productionYear?.let { add(it.toString()) } + } + dto.runTimeTicks?.ticks?.roundMinutes?.let { + add(it.toString()) + } + dto.timeRemaining?.roundMinutes?.let { + add("$it left") + } + } + title?.let { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + ) + } + subtitle?.let { + Text( + text = subtitle, + style = MaterialTheme.typography.headlineSmall, + ) + } + if (details.isNotEmpty()) { + DotSeparatedRow( + texts = details, + textStyle = MaterialTheme.typography.bodyLarge, + modifier = Modifier, + ) + } + val overviewModifier = + Modifier + .padding(0.dp) + .height(48.dp) + if (overview.isNotNullOrBlank()) { + Text( + text = overview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = overviewModifier, + ) + } else { + Spacer(overviewModifier) + } } } }