mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Add main page header & backdrop
This commit is contained in:
parent
911bef1200
commit
54bdb25d49
10 changed files with 279 additions and 44 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ val DefaultItemFields =
|
|||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.SEASON_USER_DATA,
|
||||
ItemFields.CHILD_COUNT,
|
||||
ItemFields.OVERVIEW,
|
||||
)
|
||||
|
||||
@Preview(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
val duration = dto.runTimeTicks?.ticks
|
||||
duration
|
||||
?.roundMinutes
|
||||
?.toString()
|
||||
?.let {
|
||||
add(it)
|
||||
}
|
||||
dto.seriesStudio?.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 =
|
||||
dto
|
||||
.userData
|
||||
?.rating
|
||||
?.times(100)
|
||||
?.toInt() ?: 0,
|
||||
rating100 = (it * 10).toInt(),
|
||||
onRatingChange = {},
|
||||
enabled = false,
|
||||
precision = StarRatingPrecision.HALF,
|
||||
playSoundOnFocus = true,
|
||||
modifier = Modifier.height(32.dp),
|
||||
modifier = Modifier.height(24.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(24.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,8 +65,14 @@ 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 +185,59 @@ fun MainPage(
|
|||
viewModel: MainViewModel = hiltViewModel(),
|
||||
) {
|
||||
val homeRows by viewModel.homeRows.observeAsState(listOf())
|
||||
Column(modifier = modifier) {
|
||||
// TODO header?
|
||||
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(16.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
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(
|
||||
|
|
@ -172,6 +246,9 @@ fun MainPage(
|
|||
onClickItem = {
|
||||
navigationManager.navigateTo(it.destination())
|
||||
},
|
||||
cardOnFocus = { isFocused, index ->
|
||||
focusedItem = row.items.getOrNull(index)
|
||||
},
|
||||
onLongClickItem = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
|
@ -179,3 +256,79 @@ fun MainPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue