Compare commits

..

9 Commits

29 changed files with 288 additions and 202 deletions

1
.gitignore vendored
View File

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

View File

@@ -7,6 +7,8 @@ plugins {
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.google.gms.google.services)
alias(libs.plugins.google.firebase.crashlytics)
} }
android { android {
@@ -82,6 +84,7 @@ dependencies {
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.ui)
implementation(libs.firebase.crashlytics)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) 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( return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"), id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId, seasonId = seasonId,
seasonIndex = 2,
index = 4, index = 4,
title = "The You You Are", title = "The You You Are",
synopsis = "Mark is pulled deeper into Lumon's fractured world as the team chases a clue.", 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( return Episode(
id = UUID.fromString(id), id = UUID.fromString(id),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Firefly",
seasonId = seasonId, seasonId = seasonId,
seasonIndex = 1,
index = 1, index = 1,
title = title, title = title,
synopsis = "A crew member takes the shuttle for a spin and makes a mess.", synopsis = "A crew member takes the shuttle for a spin and makes a mess.",

View File

@@ -120,7 +120,9 @@ class SeriesScreenContentTest {
Episode( Episode(
id = UUID.fromString("44444444-4444-4444-4444-444444444444"), id = UUID.fromString("44444444-4444-4444-4444-444444444444"),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId, seasonId = seasonId,
seasonIndex = 1,
index = 1, index = 1,
title = "Good News About Hell", title = "Good News About Hell",
synopsis = "Mark is promoted after an unexpected tragedy.", synopsis = "Mark is promoted after an unexpected tragedy.",
@@ -136,7 +138,9 @@ class SeriesScreenContentTest {
Episode( Episode(
id = UUID.fromString("55555555-5555-5555-5555-555555555555"), id = UUID.fromString("55555555-5555-5555-5555-555555555555"),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId, seasonId = seasonId,
seasonIndex = 1,
index = 2, index = 2,
title = "Half Loop", title = "Half Loop",
synopsis = "Mark takes the team out for a sanctioned dinner.", 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.hilt.navigation.compose.hiltViewModel
import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.EntryProviderScope
import hu.bbara.purefin.feature.browse.home.AppViewModel 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.movie.TvMovieScreen
import hu.bbara.purefin.ui.screen.series.TvSeriesScreen import hu.bbara.purefin.ui.screen.series.TvSeriesScreen
import hu.bbara.purefin.ui.screen.login.LoginScreen import hu.bbara.purefin.ui.screen.login.LoginScreen
@@ -37,7 +36,10 @@ fun EntryProviderScope<Route>.tvSeriesSection() {
fun EntryProviderScope<Route>.tvEpisodeSection() { fun EntryProviderScope<Route>.tvEpisodeSection() {
entry<Route.EpisodeRoute> { route -> 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel import hu.bbara.purefin.ui.model.MovieUiModel
import hu.bbara.purefin.ui.model.SeriesUiModel 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 import java.util.UUID
@Composable @Composable
@@ -43,7 +43,6 @@ fun PosterCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier, imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp, posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false, showSecondaryText: Boolean = false,
indicatorSize: Int = 28, indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp, indicatorPadding: Dp = 8.dp,
@@ -65,7 +64,6 @@ fun PosterCard(
modifier = modifier, modifier = modifier,
imageModifier = imageModifier, imageModifier = imageModifier,
posterWidth = posterWidth, posterWidth = posterWidth,
contentScale = contentScale,
showSecondaryText = showSecondaryText, showSecondaryText = showSecondaryText,
indicatorSize = indicatorSize, indicatorSize = indicatorSize,
indicatorPadding = indicatorPadding, indicatorPadding = indicatorPadding,
@@ -82,11 +80,10 @@ fun PosterCardContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageModifier: Modifier = Modifier, imageModifier: Modifier = Modifier,
posterWidth: Dp = 144.dp, posterWidth: Dp = 144.dp,
contentScale: Float = 1f,
showSecondaryText: Boolean = false, showSecondaryText: Boolean = false,
indicatorSize: Int = 28, indicatorSize: Int = 28,
indicatorPadding: Dp = 8.dp, indicatorPadding: Dp = 8.dp,
onFocused: () -> Unit = {}, onFocused: () -> Unit,
focusedScale: Float = 1f, focusedScale: Float = 1f,
focusedBorderWidth: Dp = 1.dp, focusedBorderWidth: Dp = 1.dp,
focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f) focusedTransformOrigin: TransformOrigin = TransformOrigin(0.5f, 0f)
@@ -155,6 +152,7 @@ fun PosterCardContent(
text = model.primaryText, text = model.primaryText,
color = scheme.onBackground, color = scheme.onBackground,
fontSize = 13.sp, fontSize = 13.sp,
lineHeight = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -167,6 +165,7 @@ fun PosterCardContent(
text = text, text = text,
color = scheme.onSurfaceVariant, color = scheme.onSurfaceVariant,
fontSize = 11.sp, fontSize = 11.sp,
lineHeight = 11.sp,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -218,53 +217,17 @@ private fun TvPlayerBottomSection(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically 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(
text = formatTime(uiState.positionMs), text = formatTime(uiState.positionMs),
color = scheme.onSurface, color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium 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(
text = formatTime(uiState.durationMs), text = formatTime(uiState.durationMs),
color = scheme.onSurface, color = scheme.onSurface,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
}
TvPlayerSeekBar( TvPlayerSeekBar(
positionMs = uiState.positionMs, positionMs = uiState.positionMs,
durationMs = uiState.durationMs, durationMs = uiState.durationMs,
@@ -330,7 +293,7 @@ private fun TvPlayerBottomSection(
) )
} }
Row( Row(
modifier = Modifier.align(Alignment.CenterEnd), modifier = Modifier.align(Alignment.CenterStart),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -356,6 +319,22 @@ private fun TvPlayerBottomSection(
.testTag(TvPlayerSubtitlesButtonTag) .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( AnimatedVisibility(
visible = isPlaylistExpanded, visible = isPlaylistExpanded,

View File

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

View File

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

View File

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

View File

@@ -237,7 +237,9 @@ private fun previewEpisode(): Episode {
return Episode( return Episode(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"), id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Severance",
seasonId = seasonId, seasonId = seasonId,
seasonIndex = 2,
index = 4, index = 4,
title = "The You You Are", 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.", 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( Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"), id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonOneId, seasonId = seasonOneId,
seasonIndex = 1,
index = 1, index = 1,
title = "A Fresh Start", title = "A Fresh Start",
synopsis = "A fractured crew tries to reassemble after a year apart.", synopsis = "A fractured crew tries to reassemble after a year apart.",
@@ -284,7 +286,9 @@ private fun previewSeries(): Series {
Episode( Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"), id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonOneId, seasonId = seasonOneId,
seasonIndex = 1,
index = 2, index = 2,
title = "Signals", title = "Signals",
synopsis = "Anomalies around the station point to a cover-up.", synopsis = "Anomalies around the station point to a cover-up.",
@@ -302,7 +306,9 @@ private fun previewSeries(): Series {
Episode( Episode(
id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"), id = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3"),
seriesId = seriesId, seriesId = seriesId,
seriesName = "Constellation",
seasonId = seasonTwoId, seasonId = seasonTwoId,
seasonIndex = 2,
index = 1, index = 1,
title = "Return Window", title = "Return Window",
synopsis = "A high-risk jump changes the rules of the mission.", 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.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) 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( data class Episode(
val id: UUID, val id: UUID,
val seriesId: UUID, val seriesId: UUID,
val seriesName: String,
val seasonId: UUID, val seasonId: UUID,
val seasonIndex: Int,
val index: Int, val index: Int,
val title: String, val title: String,
val synopsis: String, val synopsis: String,

View File

@@ -171,7 +171,11 @@ class AppViewModel @Inject constructor(
when (mediaUiModel) { when (mediaUiModel) {
is MovieUiModel -> onMovieSelected(mediaUiModel.id) is MovieUiModel -> onMovieSelected(mediaUiModel.id)
is SeriesUiModel -> onSeriesSelected(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() val controlsVisible: StateFlow<Boolean> = _controlsVisible.asStateFlow()
private val controlsAutoHidePolicy = ControlsAutoHidePolicy(DEFAULT_CONTROLS_AUTO_HIDE_MS) 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 autoHideJob: Job? = null
private var dataErrorMessage: String? = null private var dataErrorMessage: String? = null
@@ -191,27 +194,24 @@ class PlayerViewModel @Inject constructor(
} }
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
seekByCollector.clear()
playerManager.seekTo(positionMs) playerManager.seekTo(positionMs)
} }
fun seekBy(deltaMs: Long) { fun seekBy(deltaMs: Long) {
playerManager.seekBy(deltaMs) seekByCollector.seekBySoon(deltaMs)
} }
fun seekToLiveEdge() { fun seekToLiveEdge() {
seekByCollector.clear()
playerManager.seekToLiveEdge() playerManager.seekToLiveEdge()
} }
fun skipActiveSegment() { fun skipActiveSegment() {
seekByCollector.clear()
playerManager.skipActiveSegment() playerManager.skipActiveSegment()
} }
fun setControlsAutoHideDelay(autoHideDelayMs: Long) {
applyControlsAutoHideCommand(
controlsAutoHidePolicy.setAutoHideDelay(autoHideDelayMs)
)
}
fun setControlsAutoHideBlocked( fun setControlsAutoHideBlocked(
blocker: ControlsAutoHideBlocker, blocker: ControlsAutoHideBlocker,
blocked: Boolean blocked: Boolean
@@ -246,11 +246,13 @@ class PlayerViewModel @Inject constructor(
} }
fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { fun next(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
seekByCollector.clear()
playerManager.next() playerManager.next()
showControls(autoHideDelayMs) showControls(autoHideDelayMs)
} }
fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) { fun previous(autoHideDelayMs: Long = DEFAULT_CONTROLS_AUTO_HIDE_MS) {
seekByCollector.clear()
playerManager.previous() playerManager.previous()
showControls(autoHideDelayMs) showControls(autoHideDelayMs)
} }
@@ -264,6 +266,7 @@ class PlayerViewModel @Inject constructor(
} }
fun playQueueItem(id: String) { fun playQueueItem(id: String) {
seekByCollector.clear()
playerManager.play(id.toUuidOrNull() ?: return) playerManager.play(id.toUuidOrNull() ?: return)
showControls() 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) { constructor(episode: Episode) {
id = episode.id id = episode.id
primaryText = episode.title primaryText = episode.seriesName
secondaryText = episode.releaseDate secondaryText = "${episode.seasonIndex} x ${episode.index} : ${episode.title}"
description = episode.synopsis description = episode.synopsis
prefixImageUrl = episode.imageUrlPrefix prefixImageUrl = episode.imageUrlPrefix
progress = (episode.progress?.toFloat() ?: 0f) / 100f progress = (episode.progress?.toFloat() ?: 0f) / 100f

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,9 @@ media3FfmpegDecoder = "1.9.0+1"
nav3Core = "1.0.0" nav3Core = "1.0.0"
room = "2.7.0" room = "2.7.0"
slf4j = "2.0.17" slf4j = "2.0.17"
firebaseCrashlytics = "20.0.5"
googleGmsGoogleServices = "4.4.4"
googleFirebaseCrashlytics = "3.0.7"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", 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" } slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics", version.ref = "firebaseCrashlytics" }
[plugins] [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" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 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" }