Add main page header & backdrop

This commit is contained in:
Damontecres 2025-09-30 14:52:20 -04:00
parent 911bef1200
commit 54bdb25d49
No known key found for this signature in database
10 changed files with 279 additions and 44 deletions

View file

@ -14,6 +14,7 @@ import org.jellyfin.sdk.model.api.ImageType
data class BaseItem( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val imageUrl: String?, val imageUrl: String?,
val backdropImageUrl: String? = null,
) { ) {
@Transient val id = data.id @Transient val id = data.id
@ -57,6 +58,23 @@ data class BaseItem(
fun from( fun from(
dto: BaseItemDto, dto: BaseItemDto,
api: ApiClient, 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,
)
}
} }
} }

View file

@ -19,9 +19,14 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract import kotlin.contracts.contract
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
fun CharSequence?.isNotNullOrBlank(): Boolean { fun CharSequence?.isNotNullOrBlank(): Boolean {
@ -163,3 +168,16 @@ fun playOnClickSound(
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.playSoundEffect(effectType) 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
}
}

View file

@ -25,6 +25,7 @@ val DefaultItemFields =
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.SEASON_USER_DATA, ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT, ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW,
) )
@Preview( @Preview(

View file

@ -62,7 +62,7 @@ fun BannerCard(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(4.dp), .padding(4.dp),
) { ) {
if (played) { if (played && (playPercent <= 0 || playPercent >= 100)) {
Icon( Icon(
imageVector = Icons.Default.CheckCircle, imageVector = Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,

View file

@ -159,7 +159,7 @@ fun ItemCardImage(
fontFamily = FontAwesome, fontFamily = FontAwesome,
) )
} }
if (watched) { if (watched && watchedPercent?.let { it <= 0 || it >= 100 } == true) {
Icon( Icon(
imageVector = Icons.Default.CheckCircle, imageVector = Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,

View file

@ -22,6 +22,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient 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.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
@ -103,6 +105,7 @@ class SeriesViewModel
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_STREAMS, ItemFields.MEDIA_STREAMS,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING,
), ),
) )
val pager = ItemPager(api, request, viewModelScope) val pager = ItemPager(api, request, viewModelScope)
@ -111,6 +114,31 @@ class SeriesViewModel
episodes.value = pager 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 @Composable

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.components.StarRatingPrecision
import com.github.damontecres.dolphin.ui.playOnClickSound import com.github.damontecres.dolphin.ui.playOnClickSound
import com.github.damontecres.dolphin.ui.playSoundOnFocus 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 com.github.damontecres.dolphin.util.formatDateTime
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
@ -57,10 +60,14 @@ fun FocusedEpisodeHeader(
add("S${dto.parentIndexNumber} E${dto.indexNumber}") add("S${dto.parentIndexNumber} E${dto.indexNumber}")
} }
dto.premiereDate?.let { add(formatDateTime(it)) } dto.premiereDate?.let { add(formatDateTime(it)) }
dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let { val duration = dto.runTimeTicks?.ticks
add(it) duration
} ?.roundMinutes
dto.seriesStudio?.let { add(it) } ?.toString()
?.let {
add(it)
}
dto.timeRemaining?.roundMinutes?.let { add("$it left") }
} }
DotSeparatedRow( DotSeparatedRow(
texts = details, texts = details,
@ -71,33 +78,28 @@ fun FocusedEpisodeHeader(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
dto.criticRating?.let { // TODO ratings?
Text( dto.communityRating?.let {
text = "Critic: ${dto.criticRating}", if (it > 0f) {
style = MaterialTheme.typography.bodySmall, StarRating(
modifier = Modifier, 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 -> dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value val isFocused = interactionSource.collectIsFocusedAsState().value
val bgColor = val bgColor =
if (isFocused) { if (isFocused) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = .75f) MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
} else { } else {
Color.Unspecified Color.Unspecified
} }

View file

@ -65,9 +65,15 @@ fun SeriesOverview(
if (episodes.isNotEmpty()) { if (episodes.isNotEmpty()) {
// TODO focus on first episode when changing seasons // TODO focus on first episode when changing seasons
// firstItemFocusRequester.requestFocus() // firstItemFocusRequester.requestFocus()
episodes.getOrNull(seasonEpisode.episode)?.let {
viewModel.refreshEpisode(it.id, seasonEpisode.episode)
}
} }
} }
LaunchedEffect(Unit) {
}
if (series == null) { if (series == null) {
// TODO // TODO
Text(text = "Loading...") Text(text = "Loading...")
@ -109,6 +115,11 @@ fun SeriesOverview(
}, },
watchOnClick = { watchOnClick = {
// TODO toggle watched state // 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 = { moreOnClick = {
// TODO show more actions // TODO show more actions

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Tab import androidx.tv.material3.Tab
import androidx.tv.material3.TabDefaults
import androidx.tv.material3.TabRow import androidx.tv.material3.TabRow
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -133,13 +134,16 @@ fun SeriesOverviewContent(
selectedTabIndex = index selectedTabIndex = index
onFocus.invoke(SeasonEpisode(index, 0)) onFocus.invoke(SeasonEpisode(index, 0))
}, },
colors =
TabDefaults.pillIndicatorTabColors(
// TODO
),
modifier = modifier =
Modifier Modifier
.focusRequester(focusRequesters[index]), .focusRequester(focusRequesters[index]),
) { ) {
Text( Text(
text = season.name ?: "Season ${season.data.indexNumber}", text = season.name ?: "Season ${season.data.indexNumber}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
) )
} }

View file

@ -1,27 +1,51 @@
package com.github.damontecres.dolphin.ui.main package com.github.damontecres.dolphin.ui.main
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.ItemRow 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.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.NavigationManager 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.GetLatestMediaRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -159,22 +185,149 @@ fun MainPage(
viewModel: MainViewModel = hiltViewModel(), viewModel: MainViewModel = hiltViewModel(),
) { ) {
val homeRows by viewModel.homeRows.observeAsState(listOf()) val homeRows by viewModel.homeRows.observeAsState(listOf())
Column(modifier = modifier) { var focusedItem by remember { mutableStateOf<BaseItem?>(null) }
// TODO header? Box(modifier = modifier) {
LazyColumn( if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) {
verticalArrangement = Arrangement.spacedBy(16.dp), val gradientColor = MaterialTheme.colorScheme.background
contentPadding = PaddingValues(16.dp), AsyncImage(
) { model = focusedItem?.backdropImageUrl,
items(homeRows) { row -> contentDescription = null,
ItemRow( contentScale = ContentScale.Fit,
title = stringResource(row.section.nameRes), alignment = Alignment.TopEnd,
items = row.items, modifier =
onClickItem = { Modifier
navigationManager.navigateTo(it.destination()) .fillMaxHeight(.7f)
}, .fillMaxWidth(.7f)
onLongClickItem = {}, .alpha(.4f)
modifier = Modifier.fillMaxWidth(), .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)
}
} }
} }
} }