Compare commits

...

9 Commits

29 changed files with 288 additions and 202 deletions

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@
local.properties
.codex/
openspec
*.patch

View File

@@ -7,6 +7,8 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.google.gms.google.services)
alias(libs.plugins.google.firebase.crashlytics)
}
android {
@@ -82,6 +84,7 @@ dependencies {
implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.firebase.crashlytics)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "407366050869",
"project_id": "purefin-12eec",
"storage_bucket": "purefin-12eec.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:407366050869:android:8fa3540adfc514add73d7c",
"android_client_info": {
"package_name": "hu.bbara.purefin.tv"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyD8a9BnlFSmcw4v0gukC1qcyESznUUWsXI"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -105,7 +105,9 @@ class EpisodeScreenContentTest {
return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId,
seasonIndex = 2,
index = 4,
title = "The You You Are",
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.",

View File

@@ -425,7 +425,9 @@ class TvHomeContentTest {
return Episode(
id = UUID.fromString(id),
seriesId = seriesId,
seriesName = "Firefly",
seasonId = seasonId,
seasonIndex = 1,
index = 1,
title = title,
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",

View File

@@ -120,7 +120,9 @@ class SeriesScreenContentTest {
Episode(
id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId,
seasonIndex = 1,
index = 1,
title = "Good News About Hell",
synopsis = "Mark is promoted after an unexpected tragedy.",
@@ -136,7 +138,9 @@ class SeriesScreenContentTest {
Episode(
id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId,
seasonIndex = 1,
index = 2,
title = "Half Loop",
synopsis = "Mark takes the team out for a sanctioned dinner.",

View File

@@ -3,7 +3,6 @@ package hu.bbara.purefin.navigation
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.feature.browse.home.AppViewModel
import hu.bbara.purefin.ui.screen.episode.TvEpisodeScreen
import hu.bbara.purefin.ui.screen.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen
@@ -37,7 +36,10 @@ fun EntryProviderScope<Route>.tvSeriesSection() {
fun EntryProviderScope<Route>.tvEpisodeSection() {
entry<Route.EpisodeRoute> { route ->
TvEpisodeScreen(episode = route.item)
TvSeriesScreen(
series = SeriesDto(id = route.item.seriesId),
focusedEpisodeId = route.item.id
)
}
}

View File

@@ -29,12 +29,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import java.util.UUID
@Composable
@@ -43,7 +43,6 @@ fun PosterCard(
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
@@ -65,7 +64,6 @@ fun PosterCard(
modifier = modifier,
imageModifier = imageModifier,
posterWidth = posterWidth,
contentScale = contentScale,
showSecondaryText = showSecondaryText,
indicatorSize = indicatorSize,
indicatorPadding = indicatorPadding,
@@ -82,11 +80,10 @@ fun PosterCardContent(
modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false,
indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {},
onFocused: () -> Unit,
focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
@@ -155,6 +152,7 @@ fun PosterCardContent(
text = model.primaryText,
color = scheme.onBackground,
fontSize = 13.sp,
lineHeight = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -167,6 +165,7 @@ fun PosterCardContent(
text = text,
color = scheme.onSurfaceVariant,
fontSize = 11.sp,
lineHeight = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)

View File

@@ -6,7 +6,7 @@ import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.ui.unit.dp
import kotlin.math.abs
internal val TvHomeFocusedItemTopOffset = 42.dp
internal val TvHomeFocusedItemTopOffset = 48.dp
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
private const val TvHomeRowPivotParentFraction = 0.3f

View File

@@ -4,6 +4,7 @@ package hu.bbara.purefin.ui.screen.home.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -20,11 +21,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import java.util.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
@@ -46,9 +47,6 @@ fun TvHomeContent(
val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty()
val hasLibraryItems = libraries.any { libraryContent[it.id].orEmpty().isNotEmpty() }
val firstLibraryWithItemsId = libraries.firstOrNull {
libraryContent[it.id].orEmpty().isNotEmpty()
}?.id
val hasVisibleContent = hasContinueWatching || hasNextUp
val hasInitialFocusableItem = hasContinueWatching || hasNextUp || hasLibraryItems
val initialFocusRequester = remember { FocusRequester() }
@@ -68,21 +66,16 @@ fun TvHomeContent(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.testTag(TvHomeContentViewportTag),
.focusRequester(initialFocusRequester),
verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = contentPadding
) {
item(key = "tv-home-top-spacer") {
Spacer(modifier = Modifier.height(8.dp))
}
if (hasContinueWatching) {
item(key = "tv-home-continue-watching") {
TvContinueWatchingSection(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester,
firstItemTestTag = TvHomeInitialFocusTag,
rowTestTag = TvHomeContinueWatchingRowTag
)
}
}
@@ -93,9 +86,6 @@ fun TvHomeContent(
items = nextUp,
onFocusedItem = onMediaFocused,
onMediaSelected = onMediaSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
rowTestTag = TvHomeNextUpRowTag
)
}
}
@@ -108,10 +98,6 @@ fun TvHomeContent(
title = library.name,
items = libraryContent[library.id].orEmpty(),
onFocusedItem = onMediaFocused,
firstItemFocusRequester = initialFocusRequester.takeIf {
!hasContinueWatching && !hasNextUp && library.id == firstLibraryWithItemsId
},
firstItemTestTag = tvHomeLibraryFirstItemTag(library.id),
onMediaSelected = onMediaSelected
)
}

View File

@@ -5,21 +5,24 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -28,26 +31,27 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.card.PosterCardContent
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.model.MediaUiModel
import java.util.UUID
import kotlin.math.roundToInt
@@ -61,15 +65,14 @@ private val TvHomeLandscapeFocusedBorderWidth = (2f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeUnfocusedBorderWidth = (1f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeProgressBarHeight = (6f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeProgressBarPadding = (8f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataSpacing = (4f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataTopPadding = (12f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataSpacing = (1f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataTopPadding = (6f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeMetadataHorizontalPadding = (4f * TvHomeMediaCardScale).dp
private val TvHomeLandscapeTitleFontSize = (15f * TvHomeMediaCardScale).sp
private val TvHomeLandscapeSupportingFontSize = (11f * TvHomeMediaCardScale).sp
private val TvHomePosterIndicatorSize = (24f * TvHomeMediaCardScale).roundToInt()
private val TvHomePosterIndicatorPadding = (6f * TvHomeMediaCardScale).dp
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
@@ -81,168 +84,137 @@ internal fun tvHomeLibraryFirstItemTag(libraryId: UUID): String =
@Composable
fun TvContinueWatchingSection(
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
onFocusedItem: (MediaUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Continue Watching",
)
Spacer(modifier = Modifier.height(8.dp))
TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier,
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
) { item, onFocused, focusModifier ->
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.primaryImageUrl,
progress = item.progress ?: 0f,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = { onFocusedItem(item) },
imageModifier = focusModifier,
onFocusedItem = {
onFocused(item)
},
onClick = { onMediaSelected(item) }
)
}
}
}
@Composable
fun TvNextUpSection(
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
onFocusedItem: (MediaUiModel) -> Unit,
onMediaSelected: (MediaUiModel) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Next Up",
)
Spacer(modifier = Modifier.height(8.dp))
TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier,
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
) { item, onFocused, focusModifier ->
TvHomeLandscapeCard(
title = item.primaryText,
supporting = item.secondaryText,
imageUrl = item.primaryImageUrl,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocusedItem = { onFocusedItem(item) },
imageModifier = focusModifier,
onFocusedItem = { onFocused(item) },
onClick = { onMediaSelected(item) }
)
}
}
}
@Composable
fun TvLibraryPosterSection(
title: String,
items: List<MediaUiModel>,
onFocusedItem: (MediaUiModel) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
onFocusedItem: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
onMediaSelected: (MediaUiModel) -> Unit,
) {
if (items.isEmpty()) return
TvSectionHeader(
title = title,
)
Spacer(modifier = Modifier.height(8.dp))
TvHomeSectionRow(
items = items,
onFocused = onFocusedItem,
modifier = modifier,
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
) { item, onFocused, focusModifier ->
PosterCardContent(
model = item,
onClick = { onMediaSelected(item) },
onFocused = { onFocused(item) },
posterWidth = TvHomePosterCardWidth,
contentScale = TvHomeMediaCardScale,
showSecondaryText = true,
indicatorSize = TvHomePosterIndicatorSize,
indicatorPadding = TvHomePosterIndicatorPadding,
imageModifier = Modifier
.then(
if (index == 0 && firstItemFocusRequester != null) {
Modifier.focusRequester(firstItemFocusRequester)
} else {
Modifier
}
)
.then(
if (index == 0 && firstItemTestTag != null) {
Modifier.testTag(firstItemTestTag)
} else {
Modifier
}
),
onFocused = { onFocusedItem(item) },
imageModifier = focusModifier,
focusedScale = 1.07f,
focusedBorderWidth = 2.dp
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TvHomeSectionRow(
items: List<MediaUiModel>,
onFocused: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier,
rowTestTag: String? = null,
content: LazyListScope.() -> Unit,
childComponent: @Composable (
item: MediaUiModel,
onFocused: (MediaUiModel) -> Unit,
focusModifier: Modifier,
) -> Unit,
) {
val listState = rememberLazyListState()
var focusedItemId by rememberSaveable { mutableStateOf(items.first().id) }
val rowScopedFocusRequester = remember { FocusRequester() }
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow(
state = listState,
modifier = modifier
.fillMaxWidth()
.then(
if (rowTestTag != null) {
Modifier.testTag(rowTestTag)
} else {
Modifier
}
),
.focusRestorer(rowScopedFocusRequester)
.focusGroup(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
content = content
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val focusModifier =
if (item.id == focusedItemId) Modifier.focusRequester(rowScopedFocusRequester) else Modifier
childComponent(
item,
{
focusedItemId = item.id
onFocused(item)
},
focusModifier
)
}
}
}
}
@Composable
fun TvSectionHeader(
@@ -279,7 +251,10 @@ private fun TvHomeLandscapeCard(
) {
val scheme = MaterialTheme.colorScheme
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isFocused) 1.055f else 1f, label = "tv-home-landscape-scale")
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.055f else 1f,
label = "tv-home-landscape-scale"
)
val cardWidth = TvHomeLandscapeCardWidth
Column(
@@ -359,6 +334,7 @@ private fun TvHomeLandscapeCard(
text = title,
color = scheme.onBackground,
fontSize = TvHomeLandscapeTitleFontSize,
lineHeight = TvHomeLandscapeTitleFontSize,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -368,6 +344,7 @@ private fun TvHomeLandscapeCard(
text = supporting,
color = scheme.onSurfaceVariant,
fontSize = TvHomeLandscapeSupportingFontSize,
lineHeight = TvHomeLandscapeSupportingFontSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis

View File

@@ -6,7 +6,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -218,53 +217,17 @@ private fun TvPlayerBottomSection(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (uiState.activeSkippableSegmentEndMs != null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.End
) {
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = onSkipSegment,
size = 64,
label = "Skip",
modifier = expandPlaylistModifier
)
}
Spacer(modifier = Modifier.height(6.dp))
}
Text(
text = formatTime(uiState.positionMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
if (uiState.isLive) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "LIVE",
color = scheme.primary,
fontWeight = FontWeight.Bold
)
Text(
text = "Catch up",
color = scheme.onSurface,
modifier = Modifier.clickable { onSeekLiveEdge() }
)
}
} else {
Text(
text = formatTime(uiState.durationMs),
color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
}
TvPlayerSeekBar(
positionMs = uiState.positionMs,
durationMs = uiState.durationMs,
@@ -330,7 +293,7 @@ private fun TvPlayerBottomSection(
)
}
Row(
modifier = Modifier.align(Alignment.CenterEnd),
modifier = Modifier.align(Alignment.CenterStart),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -356,6 +319,22 @@ private fun TvPlayerBottomSection(
.testTag(TvPlayerSubtitlesButtonTag)
)
}
if (uiState.activeSkippableSegmentEndMs != null) {
Row(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TvIconButton(
icon = Icons.Outlined.SkipNext,
contentDescription = "Skip segment",
onClick = onSkipSegment,
size = 64,
label = "Skip",
modifier = expandPlaylistModifier
)
}
}
}
AnimatedVisibility(
visible = isPlaylistExpanded,

View File

@@ -37,6 +37,7 @@ import java.util.UUID
@Composable
fun TvSeriesScreen(
series: SeriesDto,
focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier,
viewModel: SeriesViewModel = hiltViewModel()
) {
@@ -51,6 +52,7 @@ fun TvSeriesScreen(
TvSeriesScreenContent(
series = seriesData,
onPlayEpisode = viewModel::onPlayEpisode,
focusedEpisodeId = focusedEpisodeId,
modifier = modifier
)
} else {
@@ -62,16 +64,25 @@ fun TvSeriesScreen(
internal fun TvSeriesScreenContent(
series: Series,
onPlayEpisode: (UUID) -> Unit,
focusedEpisodeId: UUID? = null,
modifier: Modifier = Modifier,
) {
val nextUpEpisode = remember(series.id) { series.nextUpEpisode() }
var selectedSeason by remember(series.id, nextUpEpisode?.seasonId) {
mutableStateOf(series.defaultSeason(nextUpEpisode))
val focusedEpisode = remember(series.id, focusedEpisodeId) {
focusedEpisodeId?.let { id ->
series.seasons
.flatMap { it.episodes }
.firstOrNull { it.id == id }
}
}
val initialEpisode = focusedEpisode ?: nextUpEpisode
var selectedSeason by remember(series.id, initialEpisode?.seasonId) {
mutableStateOf(series.defaultSeason(initialEpisode))
}
val firstContentFocusRequester = remember { FocusRequester() }
LaunchedEffect(series.id, nextUpEpisode?.id) {
if (nextUpEpisode != null) return@LaunchedEffect
LaunchedEffect(series.id, initialEpisode?.id) {
if (initialEpisode != null) return@LaunchedEffect
withFrameNanos { }
firstContentFocusRequester.requestFocus()
}
@@ -107,7 +118,7 @@ internal fun TvSeriesScreenContent(
TvEpisodeCarousel(
episodes = selectedSeason.episodes,
onPlayEpisode = { onPlayEpisode(it.id) },
focusedEpisodeId = nextUpEpisode?.id,
focusedEpisodeId = initialEpisode?.id,
modifier = Modifier.fillMaxWidth()
)
}

View File

@@ -95,7 +95,9 @@ class TvFocusedItemHeroTest {
return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = UUID.fromString("44444444-4444-4444-4444-444444444444"),
seriesName = "Love, Death & Robots",
seasonId = UUID.fromString("55555555-5555-5555-5555-555555555555"),
seasonIndex = 3,
index = 3,
title = "The Very Pulse of the Machine",
synopsis = "An astronaut faces a strange world alone.",

View File

@@ -126,7 +126,9 @@ class TvHomeHeroStateTest {
episode = Episode(
id = UUID.fromString(id),
seriesId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
seriesName = "Sample Series",
seasonId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
seasonIndex = 1,
index = 1,
title = "Next Episode",
synopsis = "Episode synopsis",

View File

@@ -237,7 +237,9 @@ private fun previewEpisode(): Episode {
return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId,
seasonIndex = 2,
index = 4,
title = "The You You Are",
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue that reframes everything they thought they understood.",

View File

@@ -268,7 +268,9 @@ private fun previewSeries(): Series {
Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"),
seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonOneId,
seasonIndex = 1,
index = 1,
title = "A Fresh Start",
synopsis = "A fractured crew tries to reassemble after a year apart.",
@@ -284,7 +286,9 @@ private fun previewSeries(): Series {
Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"),
seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonOneId,
seasonIndex = 1,
index = 2,
title = "Signals",
synopsis = "Anomalies around the station point to a cover-up.",
@@ -302,7 +306,9 @@ private fun previewSeries(): Series {
Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"),
seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonTwoId,
seasonIndex = 2,
index = 1,
title = "Return Window",
synopsis = "A high-risk jump changes the rules of the mission.",

View File

@@ -8,4 +8,6 @@ plugins {
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.google.gms.google.services) apply false
alias(libs.plugins.google.firebase.crashlytics) apply false
}

View File

@@ -5,7 +5,9 @@ import java.util.UUID
data class Episode(
val id: UUID,
val seriesId: UUID,
val seriesName: String,
val seasonId: UUID,
val seasonIndex: Int,
val index: Int,
val title: String,
val synopsis: String,

View File

@@ -171,7 +171,11 @@ class AppViewModel @Inject constructor(
when (mediaUiModel) {
is MovieUiModel -> onMovieSelected(mediaUiModel.id)
is SeriesUiModel -> onSeriesSelected(mediaUiModel.id)
is EpisodeUiModel -> onSeriesSelected(mediaUiModel.seriesId)
is EpisodeUiModel -> onEpisodeSelected(
seriesId = mediaUiModel.seriesId,
seasonId = mediaUiModel.seasonId,
episodeId = mediaUiModel.id
)
}
}

View File

@@ -49,6 +49,9 @@ class PlayerViewModel @Inject constructor(
val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS)
private val seekByCollector = SeekByCollector(viewModelScope) { deltaMs ->
playerManager.seekBy(deltaMs)
}
private var autoHideJob: Job? = null
private var dataErrorMessage: String? = null
@@ -191,27 +194,24 @@ class PlayerViewModel @Inject constructor(
}
fun seekTo(positionMs: Long) {
seekByCollector.clear()
playerManager.seekTo(positionMs)
}
fun seekBy(deltaMs: Long) {
playerManager.seekBy(deltaMs)
seekByCollector.seekBySoon(deltaMs)
}
fun seekToLiveEdge() {
seekByCollector.clear()
playerManager.seekToLiveEdge()
}
fun skipActiveSegment() {
seekByCollector.clear()
playerManager.skipActiveSegment()
}
fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)
)
}
fun setControlsAutoHideBlocked(
blocker: ControlsAutoHideBlocker,
blocked: Boolean
@@ -246,11 +246,13 @@ class PlayerViewModel @Inject constructor(
}
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
seekByCollector.clear()
playerManager.next()
showControls(autoHideDelayMs)
}
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
seekByCollector.clear()
playerManager.previous()
showControls(autoHideDelayMs)
}
@@ -264,6 +266,7 @@ class PlayerViewModel @Inject constructor(
}
fun playQueueItem(id: String) {
seekByCollector.clear()
playerManager.play(id.toUuidOrNull() ?: return)
showControls()
}

View File

@@ -0,0 +1,48 @@
package hu.bbara.purefin.player.viewmodel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class SeekByCollector(
private val scope: CoroutineScope,
private val intervalMs: Long = 120,
private val seekBy: (Long) -> Unit,
) {
private var pendingSeekMs = 0L
private var flushJob: Job? = null
fun seekBySoon(deltaMs: Long) {
pendingSeekMs += deltaMs
if (flushJob?.isActive != true) {
scheduleFlush()
}
}
fun clear() {
pendingSeekMs = 0
flushJob?.cancel()
flushJob = null
}
private fun scheduleFlush() {
flushJob = scope.launch {
delay(intervalMs)
flush()
}
}
private fun flush() {
val deltaMs = pendingSeekMs
pendingSeekMs = 0
if (deltaMs != 0L) {
seekBy(deltaMs)
}
if (pendingSeekMs != 0L) {
scheduleFlush()
} else {
flushJob = null
}
}
}

View File

@@ -129,8 +129,8 @@ class EpisodeUiModel : MediaUiModel {
constructor(episode: Episode) {
id = episode.id
primaryText = episode.title
secondaryText = episode.releaseDate
primaryText = episode.seriesName
secondaryText = "${episode.seasonIndex} x ${episode.index} : ${episode.title}"
description = episode.synopsis
prefixImageUrl = episode.imageUrlPrefix
progress = (episode.progress?.toFloat() ?: 0f) / 100f

View File

@@ -95,7 +95,9 @@ fun BaseItemDto.toEpisode(serverUrl: String): Episode {
return Episode(
id = id,
seriesId = seriesId!!,
seriesName = seriesName!!,
seasonId = parentId!!,
seasonIndex = parentIndexNumber!!,
title = name ?: "Unknown title",
index = indexNumber!!,
releaseDate = releaseDate,

View File

@@ -50,7 +50,9 @@ data class CachedSeries(
data class CachedEpisode(
val id: String,
val seriesId: String,
val seriesName: String,
val seasonId: String,
val seasonIndex: Int,
val index: Int,
val title: String,
val synopsis: String,
@@ -208,7 +210,9 @@ fun CachedSeries.toSeries(): Series? {
fun Episode.toCachedEpisode() = CachedEpisode(
id = id.toString(),
seriesId = seriesId.toString(),
seriesName = seriesName,
seasonId = seasonId.toString(),
seasonIndex = seasonIndex,
index = index,
title = title,
synopsis = synopsis,
@@ -229,7 +233,9 @@ fun CachedEpisode.toEpisode(): Episode? {
return Episode(
id = episodeId,
seriesId = seriesUuid,
seriesName = seriesName,
seasonId = seasonUuid,
seasonIndex = seasonIndex,
index = index,
title = title,
synopsis = synopsis,

View File

@@ -21,7 +21,9 @@ import java.util.UUID
data class EpisodeEntity(
@PrimaryKey val id: UUID,
val seriesId: UUID,
val seriesName: String,
val seasonId: UUID,
val seasonIndex: Int,
val index: Int,
val title: String,
val synopsis: String,

View File

@@ -23,7 +23,7 @@ import hu.bbara.purefin.data.offline.room.entity.SmartDownloadEntity
EpisodeEntity::class,
SmartDownloadEntity::class,
],
version = 10,
version = 11,
exportSchema = false
)
@TypeConverters(UuidConverters::class)

View File

@@ -246,7 +246,9 @@ class OfflineRoomMediaLocalDataSource(
private fun Episode.toEntity() = EpisodeEntity(
id = id,
seriesId = seriesId,
seriesName = seriesName,
seasonId = seasonId,
seasonIndex = seasonIndex,
index = index,
title = title,
synopsis = synopsis,
@@ -300,7 +302,9 @@ class OfflineRoomMediaLocalDataSource(
private fun EpisodeEntity.toDomain() = Episode(
id = id,
seriesId = seriesId,
seriesName = seriesName,
seasonId = seasonId,
seasonIndex = seasonIndex,
index = index,
title = title,
synopsis = synopsis,

View File

@@ -24,6 +24,9 @@ media3FfmpegDecoder = "1.9.0+1"
nav3Core = "1.0.0"
room = "2.7.0"
slf4j = "2.0.17"
firebaseCrashlytics = "20.0.5"
googleGmsGoogleServices = "4.4.4"
googleFirebaseCrashlytics = "3.0.7"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -69,6 +72,7 @@ androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", vers
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }
[plugins]
@@ -80,3 +84,5 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
google-gms-google-services = { id = "com.google.gms.google-services", version.ref = "googleGmsGoogleServices" }
google-firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "googleFirebaseCrashlytics" }