diff --git a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt index aa32e71e..1d3f1764 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt @@ -53,7 +53,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContent { val scope = rememberCoroutineScope() - DolphinTheme { + DolphinTheme(true) { Surface( modifier = Modifier.fillMaxSize(), shape = RectangleShape, diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt index 10fadceb..f0dd1544 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt @@ -1,11 +1,13 @@ package com.github.damontecres.dolphin.data.model +import com.github.damontecres.dolphin.ui.components.details.SeasonEpisode import com.github.damontecres.dolphin.ui.nav.Destination import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @Serializable @@ -19,7 +21,37 @@ data class BaseItem( @Transient val name = data.name - fun destination() = Destination.MediaItem(id, type, this) + fun destination(): Destination.MediaItem { + val result = + // Redirect episodes & seasons to their series if possible + when (type) { + BaseItemKind.EPISODE -> { + data.indexNumber?.let { episode -> + data.parentIndexNumber?.let { season -> + Destination.MediaItem( + data.seriesId!!, + BaseItemKind.SERIES, + this, + SeasonEpisode(season, episode), + ) + } + } ?: Destination.MediaItem(id, type, this) + } + + BaseItemKind.SEASON -> + data.parentIndexNumber?.let { season -> + Destination.MediaItem( + data.seriesId!!, + BaseItemKind.SERIES, + this, + SeasonEpisode(season, 0), + ) + } ?: Destination.MediaItem(id, type, this) + + else -> Destination.MediaItem(id, type, this) + } + return result + } companion object { fun from( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt index 8cccfc32..6c59019b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt @@ -31,6 +31,11 @@ fun CharSequence?.isNotNullOrBlank(): Boolean { return !this.isNullOrBlank() } +inline fun List.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? { + val index = this.indexOfFirst(predicate) + return if (index >= 0) index else null +} + /** * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly */ @@ -61,6 +66,12 @@ fun Modifier.ifElse( ifFalseModifier: Modifier = Modifier, ): Modifier = ifElse({ condition }, ifTrueModifier, ifFalseModifier) +fun Modifier.ifElse( + condition: Boolean, + ifTrueModifier: () -> Modifier, + ifFalseModifier: () -> Modifier = { Modifier }, +): Modifier = then(if (condition) ifTrueModifier.invoke() else ifFalseModifier.invoke()) + /** * Handles horizontal (Left & Right) D-Pad Keys and consumes the event(s) so that the focus doesn't * accidentally move to another element. diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt index 446697c1..d9e3071a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt @@ -75,7 +75,7 @@ fun ItemCard( } if (item == null) { - NullCard(modifier, cardWidth, cardHeight) + NullCard(modifier, cardWidth, cardHeight, interactionSource) } else { val dto = item.data // TODO better aspect ratio handling diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt index a2d4ebd8..b7cec877 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt @@ -1,5 +1,6 @@ package com.github.damontecres.dolphin.ui.cards +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -14,10 +15,12 @@ fun NullCard( modifier: Modifier = Modifier, cardWidth: Dp = 150.dp, cardHeight: Dp = 200.dp, + interactionSource: MutableInteractionSource? = null, ) { Card( modifier = modifier, onClick = {}, + interactionSource = interactionSource, ) { Column( modifier = Modifier.size(cardWidth, cardHeight), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButton.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButton.kt new file mode 100644 index 00000000..77fc003b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButton.kt @@ -0,0 +1,112 @@ +package com.github.damontecres.dolphin.ui.components + +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.ui.FontAwesome +import kotlin.time.Duration + +@Composable +fun PlayButton( + @StringRes title: Int, + resume: Duration, + icon: ImageVector, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = { onClick.invoke(resume) }, + modifier = modifier, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + imageVector = icon, + contentDescription = null, + ) + Spacer(Modifier.size(8.dp)) + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleSmall, + ) + } +} + +@Composable +fun ExpandablePlayButton( + @StringRes title: Int, + resume: Duration, + icon: ImageVector, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val isFocused = interactionSource.collectIsFocusedAsState().value + Button( + onClick = { onClick.invoke(resume) }, + modifier = modifier, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + interactionSource = interactionSource, + ) { + Icon( + imageVector = icon, + contentDescription = null, + ) + AnimatedVisibility(isFocused) { + Spacer(Modifier.size(8.dp)) + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleSmall, + ) + } + } +} + +@Composable +fun ExpandableFaButton( + @StringRes title: Int, + @StringRes iconStringRes: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val isFocused = interactionSource.collectIsFocusedAsState().value + Button( + onClick = onClick, + modifier = modifier.heightIn(min = 44.dp), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + interactionSource = interactionSource, + ) { + Text( + text = stringResource(iconStringRes), + style = MaterialTheme.typography.titleSmall, + fontSize = 18.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier, + ) + AnimatedVisibility(isFocused) { + Spacer(Modifier.size(8.dp)) + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleSmall, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt new file mode 100644 index 00000000..0dcba34f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.dolphin.ui.components + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.R +import kotlin.time.Duration + +@Composable +fun PlayButtons( + resumePosition: Duration, + playOnClick: (position: Duration) -> Unit, + moreOnClick: () -> Unit, + buttonOnFocusChanged: (FocusState) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + val firstFocus = remember { FocusRequester() } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + if (resumePosition > Duration.ZERO) { + item { +// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + PlayButton( + R.string.resume, + resumePosition, + Icons.Default.PlayArrow, + playOnClick, + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus) + .focusRequester(focusRequester), + ) + } + item { + PlayButton( + R.string.restart, + Duration.ZERO, + Icons.Default.Refresh, + playOnClick, + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } else { + item { +// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + PlayButton( + R.string.play, + Duration.ZERO, + Icons.Default.PlayArrow, + playOnClick, + Modifier + .onFocusChanged(buttonOnFocusChanged) + .focusRequester(firstFocus) + .focusRequester(focusRequester), + ) + } + } + + // More button + item { + Button( + onClick = moreOnClick, + onLongClick = {}, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null, + ) + Spacer(Modifier.size(8.dp)) + Text( + text = stringResource(R.string.more), + style = MaterialTheme.typography.titleSmall, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/SeriesDetailPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/SeriesDetailPage.kt new file mode 100644 index 00000000..129bfb3b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/SeriesDetailPage.kt @@ -0,0 +1,543 @@ +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, + episodes: List, + 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), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/VideoDetailsHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/VideoDetailsHeader.kt new file mode 100644 index 00000000..453cb997 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/details/VideoDetailsHeader.kt @@ -0,0 +1,190 @@ +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.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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +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.ui.components.DotSeparatedRow +import com.github.damontecres.dolphin.ui.components.PlayButtons +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.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.playOnClickSound +import com.github.damontecres.dolphin.ui.playSoundOnFocus +import kotlinx.coroutines.launch +import java.util.SortedMap +import kotlin.time.Duration + +@Composable +fun VideoDetailsHeader( + title: String, + subtitle: String?, + description: String?, + details: List, + moreDetails: SortedMap, + rating: Float?, + resumeTime: Duration?, + watched: Boolean, + favorite: Boolean, + bringIntoViewRequester: BringIntoViewRequester, + descriptionOnClick: () -> Unit, + moreOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + Column( + modifier = modifier, + ) { + // Title + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + style = + MaterialTheme.typography.displayMedium.copy( + shadow = + Shadow( + color = Color.DarkGray, + offset = Offset(5f, 2f), + blurRadius = 2f, + ), + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + // Subtitle + if (subtitle.isNotNullOrBlank()) { + // Title + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurface, + style = + MaterialTheme.typography.displaySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Column( + modifier = Modifier.alpha(0.75f), + ) { + // Rating + if (rating != null) { + StarRating( + rating100 = (rating * 100).toInt(), + precision = StarRatingPrecision.HALF, + onRatingChange = { + // TODO + }, + enabled = false, + modifier = + Modifier + .height(40.dp) + .padding(start = 12.dp), + playSoundOnFocus = false, + ) + } + + // Quick info + if (details.isNotEmpty()) { + DotSeparatedRow( + modifier = Modifier.padding(top = 6.dp, start = 8.dp), + textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + texts = details, + ) + } + // Description + if (description.isNotNullOrBlank()) { + 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)) + .onFocusChanged { + if (it.isFocused) { + scope.launch { bringIntoViewRequester.bringIntoView() } + } + }.playSoundOnFocus(true) + .clickable( + enabled = true, + interactionSource = interactionSource, + indication = LocalIndication.current, + ) { + playOnClickSound(context) + descriptionOnClick.invoke() + }, + ) { + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(8.dp), + ) + } + } + // Key-Values + if (moreDetails.isNotEmpty()) { + Row( + modifier = + Modifier + .padding(top = 8.dp, start = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + moreDetails.forEach { (key, value) -> + TitleValueText(key, value) + } + } + } + } + PlayButtons( + resumePosition = resumeTime ?: Duration.ZERO, + playOnClick = { position -> + // TODO + }, + moreOnClick = { moreOnClick.invoke() }, + buttonOnFocusChanged = { + scope.launch { bringIntoViewRequester.bringIntoView() } + }, + focusRequester = focusRequester, + modifier = Modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt index ec73cd09..13a6f504 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt @@ -51,6 +51,23 @@ class CollectionFolderViewModel private suspend fun setup() { if (!pager.isInitialized) { item.value?.let { item -> + val includeItemTypes = + when (item.data.collectionType) { + CollectionType.UNKNOWN -> TODO() + CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) + CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) + CollectionType.MUSIC -> TODO() + CollectionType.MUSICVIDEOS -> TODO() + CollectionType.TRAILERS -> TODO() + CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) + CollectionType.BOXSETS -> TODO() + CollectionType.BOOKS -> TODO() + CollectionType.PHOTOS -> TODO() + CollectionType.LIVETV -> TODO() + CollectionType.PLAYLISTS -> TODO() + CollectionType.FOLDERS -> TODO() + null -> TODO() + } val request = GetItemsRequest( parentId = item.id, @@ -58,7 +75,7 @@ class CollectionFolderViewModel mediaTypes = null, // recursive = true, enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - includeItemTypes = listOf(BaseItemKind.SERIES), + includeItemTypes = includeItemTypes, sortBy = listOf(ItemSortBy.SORT_NAME), sortOrder = listOf(SortOrder.ASCENDING), fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO), @@ -91,7 +108,17 @@ fun CollectionFolderDetails( pager?.let { pager -> when (library!!.collectionType) { CollectionType.UNKNOWN -> TODO() - CollectionType.MOVIES -> TODO() + + // TODO? + CollectionType.MOVIES -> + TVShowCollectionDetails( + preferences, + navigationManager, + library!!, + item!!, + pager, + modifier, + ) CollectionType.TVSHOWS -> { TVShowCollectionDetails( preferences, @@ -106,7 +133,17 @@ fun CollectionFolderDetails( CollectionType.MUSIC -> TODO() CollectionType.MUSICVIDEOS -> TODO() CollectionType.TRAILERS -> TODO() - CollectionType.HOMEVIDEOS -> TODO() + + // TODO? + CollectionType.HOMEVIDEOS -> + TVShowCollectionDetails( + preferences, + navigationManager, + library!!, + item!!, + pager, + modifier, + ) CollectionType.BOXSETS -> TODO() CollectionType.BOOKS -> TODO() CollectionType.PHOTOS -> TODO() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt index 4cf345eb..0212e665 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt @@ -1,24 +1,43 @@ package com.github.damontecres.dolphin.ui.detail -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester 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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.components.details.VideoDetailsHeader +import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -45,56 +64,99 @@ fun EpisodeDetails( Text(text = "Loading...") } else { item?.let { item -> - val dto = item.data - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(32.dp), - modifier = modifier, - ) { - item { - Text(text = item.name ?: "Unknown") - } - item { - if (dto.parentIndexNumber != null && dto.indexNumber != null) { - Text( - text = "S${dto.parentIndexNumber} E${dto.indexNumber}", - ) - } - } - dto.overview?.let { - item { - Text(text = it) - } - } - dto.userData?.playbackPositionTicks?.ticks?.let { - if (it > 60.seconds) { - item { - Button( - onClick = { - navigationManager.navigateTo( - Destination.Playback( - item.id, - it.inWholeMilliseconds, - item, - ), - ) - }, - ) { - Text(text = "Resume") - } + EpisodeDetailsContent( + item = item, + backdropImageUrl = + remember { + item.data.parentBackdropItemId?.let { + viewModel.imageUrl(it, ImageType.BACKDROP) } - } - } - item { - Button( - onClick = { - navigationManager.navigateTo(Destination.Playback(item.id, 0L, item)) - }, - ) { - Text(text = "Play") - } - } - } + }, + modifier = modifier, + ) + } + } +} + +@Composable +fun EpisodeDetailsContent( + item: BaseItem, + backdropImageUrl: String?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + val dto = item.data + val title = item.name ?: "Unknown" + val subtitle = dto.seriesName + val description = dto.overview + + val details = + buildList { + if (dto.parentIndexNumber != null && dto.indexNumber != null) { + add("S${dto.parentIndexNumber} E${dto.indexNumber}") + } + dto.mediaSources?.firstOrNull()?.runTimeTicks?.ticks?.inWholeMinutes?.toString()?.let { + add(it) + } + } + + Box( + modifier = + modifier + .fillMaxWidth() +// .fillMaxHeight(.33f) + .height(460.dp) + .bringIntoViewRequester(bringIntoViewRequester), + ) { + if (backdropImageUrl.isNotNullOrBlank()) { + Timber.v("Banner image url: $backdropImageUrl") + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = backdropImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopEnd, + modifier = + Modifier + .fillMaxSize() + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = 500f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + endX = 400f, + startX = 100f, + ), + ) + }, + ) + } + Column(modifier = Modifier.fillMaxWidth(0.8f)) { + Spacer(modifier = Modifier.height(60.dp)) + VideoDetailsHeader( + title = title, + subtitle = subtitle, + description = description, + details = details, + moreDetails = sortedMapOf(), + rating = 0f, + resumeTime = dto.userData?.playbackPositionTicks?.ticks ?: 0.seconds, + watched = dto.userData?.played ?: false, + favorite = dto.userData?.isFavorite ?: false, + bringIntoViewRequester = bringIntoViewRequester, + descriptionOnClick = {}, + moreOnClick = {}, + modifier = Modifier, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt index 5714ff73..3c75edfb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt @@ -9,7 +9,9 @@ import com.github.damontecres.dolphin.data.model.convertModel import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import java.util.UUID @@ -42,4 +44,9 @@ abstract class ItemViewModel( } } } + + fun imageUrl( + itemId: UUID, + type: ImageType, + ): String? = api.imageApi.getItemImageUrl(itemId, type) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt deleted file mode 100644 index 925e541f..00000000 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MediaItemContent.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.damontecres.dolphin.ui.detail - -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.nav.Destination -import com.github.damontecres.dolphin.ui.nav.NavigationManager -import org.jellyfin.sdk.model.api.BaseItemKind - -@Composable -fun MediaItemContent( - preferences: UserPreferences, - navigationManager: NavigationManager, - destination: Destination.MediaItem, - modifier: Modifier = Modifier, -) { - when (destination.type) { - BaseItemKind.SERIES -> SeriesDetails(preferences, navigationManager, destination, modifier) - BaseItemKind.SEASON -> SeasonDetails(preferences, navigationManager, destination, modifier) - BaseItemKind.EPISODE -> EpisodeDetails(preferences, navigationManager, destination, modifier) - BaseItemKind.MOVIE -> TODO() - BaseItemKind.VIDEO -> TODO() - BaseItemKind.COLLECTION_FOLDER -> { - CollectionFolderDetails( - preferences, - navigationManager, - destination, - modifier, - ) - } - else -> { - Text("Unsupported item type: ${destination.type}") - } - } -} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MovieDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MovieDetails.kt new file mode 100644 index 00000000..0926360c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/MovieDetails.kt @@ -0,0 +1,93 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.Button +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.model.Video +import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import dagger.hilt.android.lifecycle.HiltViewModel +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.extensions.ticks +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class MovieViewModel + @Inject + constructor( + api: ApiClient, + ) : ItemViewModel