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
|
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 com.github.damontecres.dolphin.ui.nav.Destination
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
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 androidx.navigation3.runtime.NavKey
|
||||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
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 com.github.damontecres.dolphin.util.UuidSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
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.CollectionFolderDetails
|
||||||
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
|
import com.github.damontecres.dolphin.ui.detail.EpisodeDetails
|
||||||
import com.github.damontecres.dolphin.ui.detail.MovieDetails
|
import com.github.damontecres.dolphin.ui.detail.MovieDetails
|
||||||
import com.github.damontecres.dolphin.ui.detail.SeasonDetails
|
import com.github.damontecres.dolphin.ui.detail.SeasonDetails
|
||||||
import com.github.damontecres.dolphin.ui.detail.VideoDetails
|
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.main.MainPage
|
||||||
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
import com.github.damontecres.dolphin.ui.playback.PlaybackContent
|
||||||
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
|
||||||
|
|
@ -49,7 +49,7 @@ fun DestinationContent(
|
||||||
is Destination.MediaItem ->
|
is Destination.MediaItem ->
|
||||||
when (destination.type) {
|
when (destination.type) {
|
||||||
BaseItemKind.SERIES ->
|
BaseItemKind.SERIES ->
|
||||||
SeriesDetailParent(
|
SeriesOverview(
|
||||||
preferences,
|
preferences,
|
||||||
navigationManager,
|
navigationManager,
|
||||||
destination,
|
destination,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ lifecycleRuntimeKtx = "2.9.4"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
androidx-media3 = "1.8.0"
|
androidx-media3 = "1.8.0"
|
||||||
coil = "3.3.0"
|
coil = "3.3.0"
|
||||||
jellyfin-sdk = "1.7.0-beta.5"
|
jellyfin-sdk = "1.7.0-beta.6"
|
||||||
nav3Core = "1.0.0-alpha09"
|
nav3Core = "1.0.0-alpha09"
|
||||||
lifecycleViewmodelNav3 = "1.0.0-alpha04"
|
lifecycleViewmodelNav3 = "1.0.0-alpha04"
|
||||||
material3AdaptiveNav3 = "1.0.0-SNAPSHOT"
|
material3AdaptiveNav3 = "1.0.0-SNAPSHOT"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue