mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Move files
This commit is contained in:
parent
62f557988e
commit
31b4881d7c
10 changed files with 674 additions and 549 deletions
|
|
@ -1,6 +1,6 @@
|
|||
package com.github.damontecres.dolphin.data.model
|
||||
|
||||
import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode
|
||||
import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.github.damontecres.dolphin.ui.cards
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.dolphin.ui.AppColors
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
|
||||
@Composable
|
||||
fun BannerCard(
|
||||
imageUrl: String,
|
||||
cornerText: String?,
|
||||
played: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 160.dp,
|
||||
cardHeight: Dp = cardWidth * 9 / 16,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.size(cardWidth, cardHeight),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier,
|
||||
)
|
||||
if (played || cornerText.isNotNullOrBlank()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
) {
|
||||
if (played) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.border.copy(alpha = 1f),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(48.dp)
|
||||
.padding(8.dp),
|
||||
)
|
||||
}
|
||||
if (cornerText.isNotNullOrBlank()) {
|
||||
Text(
|
||||
text = cornerText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.background(AppColors.TransparentBlack50),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,543 +0,0 @@
|
|||
package com.github.damontecres.dolphin.ui.components.details
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.MoreVert
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Tab
|
||||
import androidx.tv.material3.TabRow
|
||||
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.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.dolphin.ui.cards.ItemCard
|
||||
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
|
||||
import com.github.damontecres.dolphin.ui.components.StarRating
|
||||
import com.github.damontecres.dolphin.ui.components.StarRatingPrecision
|
||||
import com.github.damontecres.dolphin.ui.components.TitleValueText
|
||||
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
|
||||
import com.github.damontecres.dolphin.ui.ifElse
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import com.github.damontecres.dolphin.ui.playOnClickSound
|
||||
import com.github.damontecres.dolphin.ui.playSoundOnFocus
|
||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.dolphin.util.formatDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
data class SeasonEpisode(
|
||||
val season: Int,
|
||||
val episode: Int,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SeriesDetailParent(
|
||||
preferences: UserPreferences,
|
||||
navigationManager: NavigationManager,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel(),
|
||||
initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0),
|
||||
) {
|
||||
val firstItemFocusRequester = remember { FocusRequester() }
|
||||
|
||||
OneTimeLaunchedEffect {
|
||||
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
|
||||
viewModel.init(destination.itemId, destination.item, initialSeasonEpisode.season)
|
||||
}
|
||||
val series by viewModel.item.observeAsState(null)
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val episodes by viewModel.episodes.observeAsState(listOf())
|
||||
var seasonEpisode by rememberSaveable(
|
||||
destination,
|
||||
stateSaver =
|
||||
Saver(
|
||||
save = { listOf(it.season, it.episode) },
|
||||
restore = { SeasonEpisode(it[0], it[1]) },
|
||||
),
|
||||
) { mutableStateOf(initialSeasonEpisode) }
|
||||
|
||||
LaunchedEffect(episodes) {
|
||||
if (episodes.isNotEmpty()) {
|
||||
// TODO focus on first episode when changing seasons
|
||||
// firstItemFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (series == null) {
|
||||
// TODO
|
||||
Text(text = "Loading...")
|
||||
} else {
|
||||
series?.let { series ->
|
||||
SeriesDetailPage(
|
||||
series = series,
|
||||
seasons = seasons,
|
||||
episodes = episodes,
|
||||
seasonEpisode = seasonEpisode,
|
||||
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) },
|
||||
firstItemFocusRequester = firstItemFocusRequester,
|
||||
onFocus = {
|
||||
if (it.season != seasonEpisode.season) {
|
||||
viewModel.loadEpisodes(seasons[it.season]!!.id)
|
||||
}
|
||||
seasonEpisode = it
|
||||
},
|
||||
onClick = {
|
||||
val resumePosition =
|
||||
it.data.userData
|
||||
?.playbackPositionTicks
|
||||
?.ticks ?: Duration.ZERO
|
||||
navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it))
|
||||
},
|
||||
onLongClick = {
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SeriesDetailPage(
|
||||
series: BaseItem,
|
||||
seasons: List<BaseItem?>,
|
||||
episodes: List<BaseItem?>,
|
||||
seasonEpisode: SeasonEpisode,
|
||||
backdropImageUrl: String?,
|
||||
firstItemFocusRequester: FocusRequester,
|
||||
onFocus: (SeasonEpisode) -> Unit,
|
||||
onClick: (BaseItem) -> Unit,
|
||||
onLongClick: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
// TODO need to map between season index and tab index in case of missing seasons
|
||||
var selectedTabIndex by rememberSaveable(seasonEpisode) { mutableIntStateOf(seasonEpisode.season) }
|
||||
val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } }
|
||||
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
||||
val tabRowFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val focusedEpisode = episodes.getOrNull(seasonEpisode.episode)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
// .fillMaxHeight(.33f)
|
||||
.height(460.dp)
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
) {
|
||||
if (backdropImageUrl.isNotNullOrBlank()) {
|
||||
val gradientColor = MaterialTheme.colorScheme.background
|
||||
AsyncImage(
|
||||
model = backdropImageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.TopEnd,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight(.5f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, gradientColor),
|
||||
startY = 500f,
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(gradientColor, Color.Transparent),
|
||||
endX = 400f,
|
||||
startX = 100f,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
item {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(focusRequesters.size > selectedTabIndex, { Modifier.focusRestorer(focusRequesters[selectedTabIndex]) })
|
||||
.focusRequester(tabRowFocusRequester)
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
seasons.forEachIndexed { index, season ->
|
||||
season?.let { season ->
|
||||
Tab(
|
||||
selected = index == selectedTabIndex,
|
||||
onFocus = {},
|
||||
onClick = {
|
||||
selectedTabIndex = index
|
||||
onFocus.invoke(SeasonEpisode(index, 0))
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index]),
|
||||
) {
|
||||
Text(
|
||||
text = season.name ?: "Season ${season.data.indexNumber}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
series.name?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
// Episode header
|
||||
focusedEpisode?.let { ep ->
|
||||
FocusedEpisodeHeader(
|
||||
ep = ep,
|
||||
overviewOnClick = {
|
||||
// TODO show full overview dialog
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
key(seasonEpisode.season) {
|
||||
val state = rememberLazyListState()
|
||||
LazyRow(
|
||||
state = state,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(start = 32.dp),
|
||||
modifier = modifier.focusRestorer(firstItemFocusRequester),
|
||||
) {
|
||||
itemsIndexed(episodes) { index, episode ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
if (interactionSource.collectIsFocusedAsState().value) {
|
||||
onFocus.invoke(SeasonEpisode(selectedTabIndex, index))
|
||||
}
|
||||
ItemCard(
|
||||
item = episode,
|
||||
onClick = { if (episode != null) onClick.invoke(episode) },
|
||||
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(firstItemFocusRequester),
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
focusedEpisode?.let { ep ->
|
||||
FocusedEpisodeFooter(
|
||||
ep = ep,
|
||||
playOnClick = {},
|
||||
moreOnClick = {},
|
||||
watchOnClick = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FocusedEpisodeHeader(
|
||||
ep: BaseItem,
|
||||
overviewOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dto = ep.data
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = dto.episodeTitle ?: dto.name ?: "",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val details =
|
||||
buildList {
|
||||
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
|
||||
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) }
|
||||
}
|
||||
DotSeparatedRow(
|
||||
texts = details,
|
||||
textStyle = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Critic: ${dto.criticRating}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier,
|
||||
)
|
||||
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)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
||||
.playSoundOnFocus(true)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current,
|
||||
) {
|
||||
playOnClickSound(context)
|
||||
overviewOnClick.invoke()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = overview,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FocusedEpisodeFooter(
|
||||
ep: BaseItem,
|
||||
playOnClick: (Duration) -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dto = ep.data
|
||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusGroup()
|
||||
.focusProperties {
|
||||
onEnter = {
|
||||
firstFocus.tryRequestFocus()
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (resumePosition > Duration.ZERO) {
|
||||
item {
|
||||
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
ExpandablePlayButton(
|
||||
R.string.resume,
|
||||
resumePosition,
|
||||
Icons.Default.PlayArrow,
|
||||
playOnClick,
|
||||
Modifier.focusRequester(firstFocus),
|
||||
// .onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ExpandablePlayButton(
|
||||
R.string.restart,
|
||||
Duration.ZERO,
|
||||
Icons.Default.Refresh,
|
||||
playOnClick,
|
||||
Modifier,
|
||||
// .onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
ExpandablePlayButton(
|
||||
R.string.play,
|
||||
Duration.ZERO,
|
||||
Icons.Default.PlayArrow,
|
||||
playOnClick,
|
||||
Modifier.focusRequester(firstFocus),
|
||||
// .onFocusChanged(buttonOnFocusChanged)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val played = dto.userData?.played ?: false
|
||||
// Played button
|
||||
item {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// More button
|
||||
item {
|
||||
ExpandablePlayButton(
|
||||
R.string.more,
|
||||
Duration.ZERO,
|
||||
Icons.Default.MoreVert,
|
||||
{ moreOnClick.invoke() },
|
||||
Modifier,
|
||||
// .onFocusChanged(buttonOnFocusChanged)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
dto.mediaStreams
|
||||
?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||
?.let { stream ->
|
||||
stream.displayTitle?.let {
|
||||
TitleValueText(
|
||||
"Video",
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dto.mediaStreams
|
||||
?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
||||
?.let { stream ->
|
||||
stream.displayTitle?.let {
|
||||
TitleValueText(
|
||||
"Audio",
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dto.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() }
|
||||
?.mapNotNull { it.language }
|
||||
?.joinToString(", ")
|
||||
?.let {
|
||||
if (it.isNotNullOrBlank()) {
|
||||
TitleValueText(
|
||||
"Subtitles",
|
||||
it,
|
||||
modifier = Modifier.widthIn(max = 64.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.github.damontecres.dolphin.ui.detail.series
|
||||
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.damontecres.dolphin.R
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.ui.components.ExpandableFaButton
|
||||
import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton
|
||||
import com.github.damontecres.dolphin.ui.components.TitleValueText
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.dolphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Composable
|
||||
fun FocusedEpisodeFooter(
|
||||
ep: BaseItem,
|
||||
playOnClick: (Duration) -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dto = ep.data
|
||||
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusGroup()
|
||||
.focusProperties {
|
||||
onEnter = {
|
||||
firstFocus.tryRequestFocus()
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (resumePosition > Duration.ZERO) {
|
||||
item {
|
||||
// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() }
|
||||
ExpandablePlayButton(
|
||||
R.string.resume,
|
||||
resumePosition,
|
||||
Icons.Default.PlayArrow,
|
||||
playOnClick,
|
||||
Modifier.focusRequester(firstFocus),
|
||||
// .onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ExpandablePlayButton(
|
||||
R.string.restart,
|
||||
Duration.ZERO,
|
||||
Icons.Default.Refresh,
|
||||
playOnClick,
|
||||
Modifier,
|
||||
// .onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
ExpandablePlayButton(
|
||||
R.string.play,
|
||||
Duration.ZERO,
|
||||
Icons.Default.PlayArrow,
|
||||
playOnClick,
|
||||
Modifier.focusRequester(firstFocus),
|
||||
// .onFocusChanged(buttonOnFocusChanged)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val played = dto.userData?.played ?: false
|
||||
// Played button
|
||||
item {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// More button
|
||||
item {
|
||||
ExpandablePlayButton(
|
||||
R.string.more,
|
||||
Duration.ZERO,
|
||||
Icons.Default.MoreVert,
|
||||
{ moreOnClick.invoke() },
|
||||
Modifier,
|
||||
// .onFocusChanged(buttonOnFocusChanged)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
dto.mediaStreams
|
||||
?.firstOrNull { it.type == MediaStreamType.VIDEO }
|
||||
?.let { stream ->
|
||||
stream.displayTitle?.let {
|
||||
TitleValueText(
|
||||
"Video",
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dto.mediaStreams
|
||||
?.firstOrNull { it.type == MediaStreamType.AUDIO }
|
||||
?.let { stream ->
|
||||
stream.displayTitle?.let {
|
||||
TitleValueText(
|
||||
"Audio",
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dto.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE && it.language.isNotNullOrBlank() }
|
||||
?.mapNotNull { it.language }
|
||||
?.joinToString(", ")
|
||||
?.let {
|
||||
if (it.isNotNullOrBlank()) {
|
||||
TitleValueText(
|
||||
"Subtitles",
|
||||
it,
|
||||
modifier = Modifier.widthIn(max = 64.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
package com.github.damontecres.dolphin.ui.detail.series
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||
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.util.formatDateTime
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
|
||||
@Composable
|
||||
fun FocusedEpisodeHeader(
|
||||
ep: BaseItem,
|
||||
overviewOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val dto = ep.data
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = dto.episodeTitle ?: dto.name ?: "",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val details =
|
||||
buildList {
|
||||
if (dto.parentIndexNumber != null && dto.indexNumber != null) {
|
||||
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) }
|
||||
}
|
||||
DotSeparatedRow(
|
||||
texts = details,
|
||||
textStyle = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Critic: ${dto.criticRating}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier,
|
||||
)
|
||||
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)
|
||||
} else {
|
||||
Color.Unspecified
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
||||
.playSoundOnFocus(true)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current,
|
||||
) {
|
||||
playOnClickSound(context)
|
||||
overviewOnClick.invoke()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = overview,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.github.damontecres.dolphin.ui.detail.series
|
||||
|
||||
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.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.dolphin.ui.detail.SeriesViewModel
|
||||
import com.github.damontecres.dolphin.ui.nav.Destination
|
||||
import com.github.damontecres.dolphin.ui.nav.NavigationManager
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
data class SeasonEpisode(
|
||||
val season: Int,
|
||||
val episode: Int,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SeriesOverview(
|
||||
preferences: UserPreferences,
|
||||
navigationManager: NavigationManager,
|
||||
destination: Destination.MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SeriesViewModel = hiltViewModel(),
|
||||
initialSeasonEpisode: SeasonEpisode = SeasonEpisode(0, 0),
|
||||
) {
|
||||
val firstItemFocusRequester = remember { FocusRequester() }
|
||||
|
||||
OneTimeLaunchedEffect {
|
||||
Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode")
|
||||
viewModel.init(destination.itemId, destination.item, initialSeasonEpisode.season)
|
||||
}
|
||||
val series by viewModel.item.observeAsState(null)
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val episodes by viewModel.episodes.observeAsState(listOf())
|
||||
var seasonEpisode by rememberSaveable(
|
||||
destination,
|
||||
stateSaver =
|
||||
Saver(
|
||||
save = { listOf(it.season, it.episode) },
|
||||
restore = { SeasonEpisode(it[0], it[1]) },
|
||||
),
|
||||
) { mutableStateOf(initialSeasonEpisode) }
|
||||
|
||||
LaunchedEffect(episodes) {
|
||||
if (episodes.isNotEmpty()) {
|
||||
// TODO focus on first episode when changing seasons
|
||||
// firstItemFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
if (series == null) {
|
||||
// TODO
|
||||
Text(text = "Loading...")
|
||||
} else {
|
||||
series?.let { series ->
|
||||
SeriesOverviewContent(
|
||||
series = series,
|
||||
seasons = seasons,
|
||||
episodes = episodes,
|
||||
seasonEpisode = seasonEpisode,
|
||||
backdropImageUrl = remember { viewModel.imageUrl(series.id, ImageType.BACKDROP) },
|
||||
firstItemFocusRequester = firstItemFocusRequester,
|
||||
onFocus = {
|
||||
if (it.season != seasonEpisode.season) {
|
||||
viewModel.loadEpisodes(seasons[it.season]!!.id)
|
||||
}
|
||||
seasonEpisode = it
|
||||
},
|
||||
onClick = {
|
||||
val resumePosition =
|
||||
it.data.userData
|
||||
?.playbackPositionTicks
|
||||
?.ticks ?: Duration.ZERO
|
||||
navigationManager.navigateTo(Destination.Playback(it.id, resumePosition.inWholeMilliseconds, it))
|
||||
},
|
||||
onLongClick = {
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package com.github.damontecres.dolphin.ui.detail.series
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.TabRow
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.ui.cards.ItemCard
|
||||
import com.github.damontecres.dolphin.ui.ifElse
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
|
||||
@Composable
|
||||
fun SeriesOverviewContent(
|
||||
series: BaseItem,
|
||||
seasons: List<BaseItem?>,
|
||||
episodes: List<BaseItem?>,
|
||||
seasonEpisode: SeasonEpisode,
|
||||
backdropImageUrl: String?,
|
||||
firstItemFocusRequester: FocusRequester,
|
||||
onFocus: (SeasonEpisode) -> Unit,
|
||||
onClick: (BaseItem) -> Unit,
|
||||
onLongClick: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
// TODO need to map between season index and tab index in case of missing seasons
|
||||
var selectedTabIndex by rememberSaveable(seasonEpisode) { mutableIntStateOf(seasonEpisode.season) }
|
||||
val focusRequesters = remember(seasons.size) { List(seasons.size) { FocusRequester() } }
|
||||
var resolvedTabIndex by remember { mutableIntStateOf(selectedTabIndex) }
|
||||
val tabRowFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val focusedEpisode = episodes.getOrNull(seasonEpisode.episode)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
// .fillMaxHeight(.33f)
|
||||
.height(460.dp)
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
) {
|
||||
if (backdropImageUrl.isNotNullOrBlank()) {
|
||||
val gradientColor = MaterialTheme.colorScheme.background
|
||||
AsyncImage(
|
||||
model = backdropImageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.TopEnd,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight(.5f)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, gradientColor),
|
||||
startY = 500f,
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(gradientColor, Color.Transparent),
|
||||
endX = 400f,
|
||||
startX = 100f,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
item {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(focusRequesters.size > selectedTabIndex, { Modifier.focusRestorer(focusRequesters[selectedTabIndex]) })
|
||||
.focusRequester(tabRowFocusRequester)
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
seasons.forEachIndexed { index, season ->
|
||||
season?.let { season ->
|
||||
Tab(
|
||||
selected = index == selectedTabIndex,
|
||||
onFocus = {},
|
||||
onClick = {
|
||||
selectedTabIndex = index
|
||||
onFocus.invoke(SeasonEpisode(index, 0))
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index]),
|
||||
) {
|
||||
Text(
|
||||
text = season.name ?: "Season ${season.data.indexNumber}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
series.name?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
// Episode header
|
||||
focusedEpisode?.let { ep ->
|
||||
FocusedEpisodeHeader(
|
||||
ep = ep,
|
||||
overviewOnClick = {
|
||||
// TODO show full overview dialog
|
||||
},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
key(seasonEpisode.season) {
|
||||
val state = rememberLazyListState()
|
||||
LazyRow(
|
||||
state = state,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(start = 32.dp),
|
||||
modifier = modifier.focusRestorer(firstItemFocusRequester),
|
||||
) {
|
||||
itemsIndexed(episodes) { index, episode ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
if (interactionSource.collectIsFocusedAsState().value) {
|
||||
onFocus.invoke(SeasonEpisode(selectedTabIndex, index))
|
||||
}
|
||||
ItemCard(
|
||||
item = episode,
|
||||
onClick = { if (episode != null) onClick.invoke(episode) },
|
||||
onLongClick = { if (episode != null) onLongClick.invoke(episode) },
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(firstItemFocusRequester),
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
focusedEpisode?.let { ep ->
|
||||
FocusedEpisodeFooter(
|
||||
ep = ep,
|
||||
playOnClick = {},
|
||||
moreOnClick = {},
|
||||
watchOnClick = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ package com.github.damontecres.dolphin.ui.nav
|
|||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode
|
||||
import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
|
||||
import com.github.damontecres.dolphin.util.UuidSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode
|
||||
import com.github.damontecres.dolphin.ui.components.details.SeriesDetailParent
|
||||
import com.github.damontecres.dolphin.ui.detail.CollectionFolderDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.MovieDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.SeasonDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.VideoDetails
|
||||
import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
|
||||
import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview
|
||||
import com.github.damontecres.dolphin.ui.main.MainPage
|
||||
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
||||
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
|
||||
|
|
@ -49,7 +49,7 @@ fun DestinationContent(
|
|||
is Destination.MediaItem ->
|
||||
when (destination.type) {
|
||||
BaseItemKind.SERIES ->
|
||||
SeriesDetailParent(
|
||||
SeriesOverview(
|
||||
preferences,
|
||||
navigationManager,
|
||||
destination,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ lifecycleRuntimeKtx = "2.9.4"
|
|||
activityCompose = "1.11.0"
|
||||
androidx-media3 = "1.8.0"
|
||||
coil = "3.3.0"
|
||||
jellyfin-sdk = "1.7.0-beta.5"
|
||||
jellyfin-sdk = "1.7.0-beta.6"
|
||||
nav3Core = "1.0.0-alpha09"
|
||||
lifecycleViewmodelNav3 = "1.0.0-alpha04"
|
||||
material3AdaptiveNav3 = "1.0.0-SNAPSHOT"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue