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(
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,
)
}
}
}

View file

@ -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
}
}

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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),
)
}

View file

@ -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<BaseItem?>(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)
}
}
}
}