diff --git a/.gitignore b/.gitignore index 3d0b40dc..18db251d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,8 @@ scripts/live_app_logs.sh docs/tv_qr_login.sql docss benchmark -.mcp.json \ No newline at end of file +.mcp.json +db/ +backups/ +.supabase_db.env +scripts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 794f262d..d48ac6c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,22 +138,23 @@ configurations.all { } dependencies { + val composeBom = platform("androidx.compose:compose-bom:2025.02.00") + baselineProfile(project(":benchmark")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.profileinstaller) implementation("androidx.recyclerview:recyclerview:1.4.0") - implementation(platform(libs.androidx.compose.bom)) + implementation(composeBom) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) + implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.material:material-icons-extended") - implementation(libs.androidx.tv.foundation) - implementation(libs.androidx.tv.material) + implementation("androidx.tv:tv-material:1.0.1") implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) + implementation("androidx.activity:activity-compose:1.11.0") // Hilt implementation(libs.hilt.android) @@ -248,6 +249,6 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1") testImplementation("io.mockk:mockk:1.13.12") - debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index ee0c49ee..7bc708e0 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -60,7 +60,7 @@ fun NuvioNavHost( if (isStreamToPlayer(from, to) && isAutoPlayNav) { EnterTransition.None } else { - fadeIn(animationSpec = tween(150)) + fadeIn(animationSpec = tween(350)) } }, exitTransition = { @@ -72,7 +72,7 @@ fun NuvioNavHost( if (isStreamToPlayer(from, to) && isAutoPlayNav) { ExitTransition.None } else { - fadeOut(animationSpec = tween(150)) + fadeOut(animationSpec = tween(350)) } }, popEnterTransition = { @@ -84,7 +84,7 @@ fun NuvioNavHost( if (isPlayerToStream(from, to) && isAutoPlayNav) { EnterTransition.None } else { - fadeIn(animationSpec = tween(150)) + fadeIn(animationSpec = tween(350)) } }, popExitTransition = { @@ -96,7 +96,7 @@ fun NuvioNavHost( if (isPlayerToStream(from, to) && isAutoPlayNav) { ExitTransition.None } else { - fadeOut(animationSpec = tween(150)) + fadeOut(animationSpec = tween(350)) } } ) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/GridHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/GridHomeContent.kt index a06c9df4..3774d145 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/GridHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/GridHomeContent.kt @@ -16,6 +16,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -30,10 +34,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.tv.foundation.lazy.grid.TvGridCells -import androidx.tv.foundation.lazy.grid.TvGridItemSpan -import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid -import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState import androidx.tv.material3.Border import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme @@ -65,7 +65,7 @@ fun GridHomeContent( posterCardStyle: PosterCardStyle = PosterCardDefaults.Style, onSaveGridFocusState: (Int, Int) -> Unit ) { - val gridState = rememberTvLazyGridState( + val gridState = rememberLazyGridState( initialFirstVisibleItemIndex = gridFocusState.verticalScrollIndex, initialFirstVisibleItemScrollOffset = gridFocusState.verticalScrollOffset ) @@ -143,9 +143,9 @@ fun GridHomeContent( } Box(modifier = Modifier.fillMaxSize()) { - TvLazyVerticalGrid( + LazyVerticalGrid( state = gridState, - columns = TvGridCells.Adaptive(minSize = posterCardStyle.width), + columns = GridCells.Adaptive(minSize = posterCardStyle.width), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( start = 24.dp, @@ -164,7 +164,7 @@ fun GridHomeContent( is GridItem.Hero -> { item( key = "hero", - span = { TvGridItemSpan(maxLineSpan) }, + span = { GridItemSpan(maxLineSpan) }, contentType = "hero" ) { HeroCarousel( @@ -187,7 +187,7 @@ fun GridHomeContent( continueWatchingInserted = true item( key = "continue_watching", - span = { TvGridItemSpan(maxLineSpan) }, + span = { GridItemSpan(maxLineSpan) }, contentType = "continue_watching" ) { GridContinueWatchingSection( @@ -231,7 +231,7 @@ fun GridHomeContent( item( key = "divider_${index}_${gridItem.catalogId}_${gridItem.addonId}_${gridItem.type}", - span = { TvGridItemSpan(maxLineSpan) }, + span = { GridItemSpan(maxLineSpan) }, contentType = "divider" ) { SectionDivider( @@ -254,7 +254,7 @@ fun GridHomeContent( } item( key = "content_${index}_${gridItem.catalogId}_${gridItem.item.id}", - span = { TvGridItemSpan(1) }, + span = { GridItemSpan(1) }, contentType = "content" ) { GridContentCard( @@ -287,7 +287,7 @@ fun GridHomeContent( } item( key = "see_all_${gridItem.catalogId}_${gridItem.addonId}_${gridItem.type}", - span = { TvGridItemSpan(1) }, + span = { GridItemSpan(1) }, contentType = "see_all" ) { SeeAllGridCard( @@ -309,7 +309,7 @@ fun GridHomeContent( if (!continueWatchingInserted && uiState.continueWatchingItems.isNotEmpty()) { item( key = "continue_watching_fallback", - span = { TvGridItemSpan(maxLineSpan) }, + span = { GridItemSpan(maxLineSpan) }, contentType = "continue_watching" ) { GridContinueWatchingSection( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt index 3ed3f249..83cfb6ec 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt @@ -1362,7 +1362,7 @@ class HomeViewModel @Inject constructor( focusedItemIndex: Int, catalogRowScrollStates: Map ) { - _focusState.value = HomeScreenFocusState( + val nextState = HomeScreenFocusState( verticalScrollIndex = verticalScrollIndex, verticalScrollOffset = verticalScrollOffset, focusedRowIndex = focusedRowIndex, @@ -1370,6 +1370,8 @@ class HomeViewModel @Inject constructor( catalogRowScrollStates = catalogRowScrollStates, hasSavedFocus = true ) + if (_focusState.value == nextState) return + _focusState.value = nextState } /** diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt index 9e5f0b95..db50acea 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/ModernHomeContent.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos @@ -58,6 +59,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -193,9 +195,8 @@ fun ModernHomeContent( key = "continue_watching", title = "Continue Watching", globalRowIndex = -1, - items = uiState.continueWatchingItems.mapIndexed { index, item -> + items = uiState.continueWatchingItems.map { item -> buildContinueWatchingItem( - index = index, item = item, useLandscapePosters = useLandscapePosters ) @@ -205,6 +206,7 @@ fun ModernHomeContent( } visibleCatalogRows.forEachIndexed { index, row -> + val rowItemOccurrenceCounts = mutableMapOf() add( HeroCarouselRow( key = catalogRowKey(row), @@ -219,12 +221,14 @@ fun ModernHomeContent( supportsSkip = row.supportsSkip, hasMore = row.hasMore, isLoading = row.isLoading, - items = row.items.mapIndexed { itemIndex, item -> + items = row.items.map { item -> + val occurrence = rowItemOccurrenceCounts.getOrDefault(item.id, 0) + rowItemOccurrenceCounts[item.id] = occurrence + 1 buildCatalogItem( - index = itemIndex, item = item, row = row, - useLandscapePosters = useLandscapePosters + useLandscapePosters = useLandscapePosters, + occurrence = occurrence ) } ) @@ -243,8 +247,9 @@ fun ModernHomeContent( } val focusedItemByRow = remember { mutableStateMapOf() } - val itemFocusRequesters = remember { mutableMapOf>() } + val itemFocusRequesters = remember { mutableMapOf>() } val rowListStates = remember { mutableMapOf() } + val loadMoreRequestedTotals = remember { mutableMapOf() } var activeRowKey by remember { mutableStateOf(null) } var pendingRowFocusKey by remember { mutableStateOf(null) } var pendingRowFocusIndex by remember { mutableStateOf(null) } @@ -256,9 +261,9 @@ fun ModernHomeContent( var expandedCatalogFocusKey by remember { mutableStateOf(null) } var expansionInteractionNonce by remember { mutableIntStateOf(0) } - fun requesterFor(rowKey: String, index: Int): FocusRequester { + fun requesterFor(rowKey: String, itemKey: String): FocusRequester { val byIndex = itemFocusRequesters.getOrPut(rowKey) { mutableMapOf() } - return byIndex.getOrPut(index) { FocusRequester() } + return byIndex.getOrPut(itemKey) { FocusRequester() } } fun moveToRow(direction: Int): Boolean { @@ -324,6 +329,12 @@ fun ModernHomeContent( focusedItemByRow.keys.retainAll(activeKeys) itemFocusRequesters.keys.retainAll(activeKeys) rowListStates.keys.retainAll(activeKeys) + loadMoreRequestedTotals.keys.retainAll(activeKeys) + carouselRows.forEach { row -> + val rowRequesters = itemFocusRequesters[row.key] ?: return@forEach + val activeItemKeys = row.items.mapTo(mutableSetOf()) { it.key } + rowRequesters.keys.retainAll(activeItemKeys) + } val activeCatalogItemIds = carouselRows .flatMap { row -> row.items.mapNotNull { item -> @@ -400,59 +411,313 @@ fun ModernHomeContent( } } - DisposableEffect(activeRow?.key, activeItemIndex, carouselRows) { + val latestActiveRow by rememberUpdatedState(activeRow) + val latestActiveItemIndex by rememberUpdatedState(activeItemIndex) + val latestCarouselRows by rememberUpdatedState(carouselRows) + val latestFocusedItemByRow by rememberUpdatedState(focusedItemByRow) + DisposableEffect(Unit) { onDispose { - val row = activeRow + val row = latestActiveRow val focusedRowIndex = row?.globalRowIndex ?: 0 - val catalogRowScrollStates = carouselRows + val catalogRowScrollStates = latestCarouselRows .filter { it.globalRowIndex >= 0 } - .associate { rowState -> rowState.key to (focusedItemByRow[rowState.key] ?: 0) } + .associate { rowState -> rowState.key to (latestFocusedItemByRow[rowState.key] ?: 0) } onSaveFocusState( 0, 0, focusedRowIndex, - activeItemIndex, + latestActiveItemIndex, catalogRowScrollStates ) } } + @Composable + fun ModernActiveRowContent(activeRowStateKey: String?, activeRowTitleBottom: Dp) { + val row = carouselRows.firstOrNull { it.key == activeRowStateKey } + val portraitBaseWidth = uiState.posterCardWidthDp.dp + val continueWatchingScale = 1.34f + val continueWatchingCardWidth = portraitBaseWidth * 1.24f * continueWatchingScale + val continueWatchingCardHeight = continueWatchingCardWidth / 1.77f + + Column { + Text( + text = row?.title.orEmpty(), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = NuvioColors.TextPrimary, + modifier = Modifier.padding(start = 52.dp, bottom = activeRowTitleBottom) + ) + + row?.let { resolvedRow -> + val rowListState = rowListStates.getOrPut(resolvedRow.key) { + LazyListState( + firstVisibleItemIndex = focusState.catalogRowScrollStates[resolvedRow.key] ?: 0 + ) + } + val currentRowState = rememberUpdatedState(resolvedRow) + + LaunchedEffect(resolvedRow.key, pendingRowFocusKey, pendingRowFocusIndex) { + if (pendingRowFocusKey != resolvedRow.key) return@LaunchedEffect + val targetIndex = (pendingRowFocusIndex ?: 0) + .coerceIn(0, (resolvedRow.items.size - 1).coerceAtLeast(0)) + val targetItemKey = resolvedRow.items.getOrNull(targetIndex)?.key ?: return@LaunchedEffect + val requester = requesterFor(resolvedRow.key, targetItemKey) + var didFocus = false + var didScrollToTarget = false + repeat(20) { + didFocus = runCatching { + requester.requestFocus() + true + }.getOrDefault(false) + if (didFocus) { + return@repeat + } + if (!didScrollToTarget) { + runCatching { rowListState.scrollToItem(targetIndex) } + didScrollToTarget = true + } + withFrameNanos { } + } + if (!didFocus) { + val fallbackIndex = rowListState.firstVisibleItemIndex + .coerceIn(0, (resolvedRow.items.size - 1).coerceAtLeast(0)) + val fallbackItemKey = resolvedRow.items.getOrNull(fallbackIndex)?.key + didFocus = runCatching { + if (fallbackItemKey != null) { + requesterFor(resolvedRow.key, fallbackItemKey).requestFocus() + } + true + }.getOrDefault(false) + } + if (didFocus) { + pendingRowFocusKey = null + pendingRowFocusIndex = null + } + } + + LaunchedEffect( + resolvedRow.key, + rowListState + ) { + snapshotFlow { + val lastVisible = rowListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = rowListState.layoutInfo.totalItemsCount + lastVisible to total + } + .distinctUntilChanged() + .collect { (lastVisible, total) -> + if (total <= 0) return@collect + val rowState = currentRowState.value + val catalogId = rowState.catalogId + val addonId = rowState.addonId + val apiType = rowState.apiType + val isNearEnd = lastVisible >= total - 4 + if (!isNearEnd) { + loadMoreRequestedTotals.remove(rowState.key) + return@collect + } + val lastRequestedTotal = loadMoreRequestedTotals[rowState.key] + if (rowState.supportsSkip && + rowState.hasMore && + !rowState.isLoading && + !catalogId.isNullOrBlank() && + !addonId.isNullOrBlank() && + !apiType.isNullOrBlank() && + lastRequestedTotal != total + ) { + loadMoreRequestedTotals[rowState.key] = total + onLoadMoreCatalog(catalogId, addonId, apiType) + } + } + } + + LazyRow( + state = rowListState, + modifier = Modifier.focusRestorer { + val rememberedIndex = (focusedItemByRow[resolvedRow.key] ?: 0) + .coerceIn(0, (resolvedRow.items.size - 1).coerceAtLeast(0)) + val fallbackIndex = rowListState.firstVisibleItemIndex + .coerceIn(0, (resolvedRow.items.size - 1).coerceAtLeast(0)) + val restoreIndex = if (rememberedIndex in resolvedRow.items.indices) { + rememberedIndex + } else { + fallbackIndex + } + val itemKey = resolvedRow.items.getOrNull(restoreIndex)?.key ?: resolvedRow.items.first().key + requesterFor(resolvedRow.key, itemKey) + }, + contentPadding = PaddingValues(horizontal = 52.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + itemsIndexed( + items = resolvedRow.items, + key = { _, item -> item.key }, + contentType = { _, item -> + when (item.payload) { + is ModernPayload.ContinueWatching -> "modern_cw_card" + is ModernPayload.Catalog -> "modern_catalog_card" + } + } + ) { index, item -> + val requester = requesterFor(resolvedRow.key, item.key) + val onFocused = { + focusedItemByRow[resolvedRow.key] = index + activeRowKey = resolvedRow.key + heroItem = item.heroPreview + if (resolvedRow.key == "continue_watching") { + lastFocusedContinueWatchingIndex = index + focusedCatalogSelection = null + } + } + when (val payload = item.payload) { + is ModernPayload.ContinueWatching -> { + ContinueWatchingCard( + item = payload.item, + onClick = { onContinueWatchingClick(payload.item) }, + onLongPress = { optionsItem = payload.item }, + cardWidth = continueWatchingCardWidth, + imageHeight = continueWatchingCardHeight, + modifier = Modifier + .focusRequester(requester) + .onFocusChanged { + if (it.isFocused) { + onFocused() + } + } + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.DirectionUp -> moveToRow(-1) + Key.DirectionDown -> moveToRow(1) + else -> false + } + } + ) + } + + is ModernPayload.Catalog -> { + val focusKey = "${resolvedRow.key}::${payload.itemId}" + val suppressCardExpansionForHeroTrailer = + effectiveAutoplayEnabled && + trailerPlaybackTarget == + FocusedPosterTrailerPlaybackTarget.HERO_MEDIA + val isBackdropExpanded = + effectiveExpandEnabled && + expandedCatalogFocusKey == focusKey && + !suppressCardExpansionForHeroTrailer + val playTrailerInExpandedCard = + effectiveAutoplayEnabled && + trailerPlaybackTarget == + FocusedPosterTrailerPlaybackTarget.EXPANDED_CARD && + isBackdropExpanded + val trailerPreviewUrl = if (playTrailerInExpandedCard) { + trailerPreviewUrls[payload.itemId] + } else { + null + } + ModernCarouselCard( + item = item, + useLandscapePosters = useLandscapePosters, + showLabels = uiState.posterLabelsEnabled, + cardCornerRadius = uiState.posterCardCornerRadiusDp.dp, + cardWidth = if (useLandscapePosters) { + (uiState.posterCardWidthDp.dp * 1.24f) * + (if (showNextRowPreview) 1.08f else 1.34f) + } else { + (uiState.posterCardWidthDp.dp * 0.84f) * + (if (showNextRowPreview) 1.14f else 1.08f) + }, + cardHeight = if (useLandscapePosters) { + ( + if (useLandscapePosters) { + (uiState.posterCardWidthDp.dp * 1.24f) * + (if (showNextRowPreview) 1.08f else 1.34f) + } else { + (uiState.posterCardWidthDp.dp * 0.84f) * + (if (showNextRowPreview) 1.14f else 1.08f) + } + ) / 1.77f + } else { + (uiState.posterCardHeightDp.dp * 0.84f) * + (if (showNextRowPreview) 1.14f else 1.08f) + }, + focusedPosterBackdropExpandEnabled = effectiveExpandEnabled, + isBackdropExpanded = isBackdropExpanded, + playTrailerInExpandedCard = playTrailerInExpandedCard, + focusedPosterBackdropTrailerMuted = uiState.focusedPosterBackdropTrailerMuted, + trailerPreviewUrl = trailerPreviewUrl, + focusRequester = requester, + onFocused = { + onFocused() + focusedCatalogSelection = FocusedCatalogSelection( + focusKey = focusKey, + payload = payload + ) + }, + onClick = { + onNavigateToDetail( + payload.itemId, + payload.itemType, + payload.addonBaseUrl + ) + }, + onMoveUp = { moveToRow(-1) }, + onMoveDown = { moveToRow(1) }, + onBackdropInteraction = { + expansionInteractionNonce++ + }, + onTrailerEnded = { + expandedCatalogFocusKey = null + } + ) + } + } + } + } + + if (showNextRowPreview) { + val rowIndex = rowIndexByKey[resolvedRow.key] ?: -1 + val previewRow = carouselRows.getOrNull(rowIndex + 1) + val posterScale = if (showNextRowPreview) { + if (useLandscapePosters) 1.08f else 1.14f + } else { + if (useLandscapePosters) 1.34f else 1.08f + } + val portraitBaseWidth = uiState.posterCardWidthDp.dp + val portraitBaseHeight = uiState.posterCardHeightDp.dp + val previewCardWidth = if (useLandscapePosters) { + portraitBaseWidth * 1.24f * posterScale + } else { + portraitBaseWidth * 0.84f * posterScale + } + val previewCardHeight = if (useLandscapePosters) { + previewCardWidth / 1.77f + } else { + portraitBaseHeight * 0.84f * posterScale + } + val previewVisibleHeight = if (useLandscapePosters) { + previewCardHeight * 0.24f + } else { + previewCardHeight * 0.18f + } + ModernNextRowPreviewStrip( + previewRow = previewRow, + rowHorizontalPadding = 52.dp, + rowItemSpacing = 12.dp, + previewVisibleHeight = previewVisibleHeight, + previewCardWidth = previewCardWidth, + previewCardHeight = previewCardHeight + ) + } + } + } + } + BoxWithConstraints( modifier = Modifier.fillMaxSize() ) { - val previewRowEnabled = showNextRowPreview - val posterScale = when { - previewRowEnabled && useLandscapePosters -> 1.08f - previewRowEnabled -> 1.14f - useLandscapePosters -> 1.34f - else -> 1.08f - } val rowHorizontalPadding = 52.dp - val rowItemSpacing = 12.dp - val portraitBaseWidth = uiState.posterCardWidthDp.dp - val portraitBaseHeight = uiState.posterCardHeightDp.dp - val activeCardWidth = if (useLandscapePosters) { - portraitBaseWidth * 1.24f * posterScale - } else { - portraitBaseWidth * 0.84f * posterScale - } - val activeCardHeight = if (useLandscapePosters) { - activeCardWidth / 1.77f - } else { - portraitBaseHeight * 0.84f * posterScale - } - val previewCardWidth = activeCardWidth - val previewCardHeight = activeCardHeight - val continueWatchingScale = 1.34f - val continueWatchingCardWidth = portraitBaseWidth * 1.24f * continueWatchingScale - val continueWatchingCardHeight = continueWatchingCardWidth / 1.77f - val cardCornerRadius = uiState.posterCardCornerRadiusDp.dp - val previewVisibleHeight = if (useLandscapePosters) { - previewCardHeight * 0.24f - } else { - previewCardHeight * 0.18f - } val resolvedHero = heroItem ?: activeRow?.items?.firstOrNull()?.heroPreview val fallbackBackdrop = remember(activeRow?.key, activeRow?.items) { @@ -485,91 +750,34 @@ fun ModernHomeContent( val catalogBottomPadding = if (shouldRenderPreviewRow) 8.dp else 18.dp val heroToCatalogGap = if (shouldRenderPreviewRow) 8.dp else 18.dp val activeRowTitleBottom = if (shouldRenderPreviewRow) 2.dp else 6.dp - val localContext = LocalContext.current + val localDensity = LocalDensity.current val bgColor = NuvioColors.Background + val heroMediaWidthPx = remember(maxWidth, localDensity) { + with(localDensity) { (maxWidth * 0.75f).roundToPx() } + } + val heroMediaHeightPx = remember(maxHeight, localDensity) { + with(localDensity) { (maxHeight * MODERN_HERO_BACKDROP_HEIGHT_FRACTION).roundToPx() } + } val heroMediaModifier = Modifier .align(Alignment.TopEnd) .fillMaxWidth(0.75f) .fillMaxHeight(MODERN_HERO_BACKDROP_HEIGHT_FRACTION) - Box(modifier = heroMediaModifier) { - Crossfade( - targetState = heroBackdrop, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = heroBackdropAlpha }, - animationSpec = tween(durationMillis = 350), - label = "modernHeroBackground" - ) { imageUrl -> - AsyncImage( - model = ImageRequest.Builder(localContext) - .data(imageUrl) - .crossfade(true) - .build(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - alignment = Alignment.TopEnd - ) - } - - if (shouldPlayHeroTrailer) { - TrailerPlayer( - trailerUrl = heroTrailerUrl, - isPlaying = true, - onEnded = { expandedCatalogFocusKey = null }, - onFirstFrameRendered = { heroTrailerFirstFrameRendered = true }, - muted = uiState.focusedPosterBackdropTrailerMuted, - cropToFill = true, - overscanZoom = MODERN_TRAILER_OVERSCAN_ZOOM, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { alpha = heroTrailerAlpha } - ) - } - - // Keep the same hero gradients for both backdrop and trailer. - Box( - modifier = Modifier - .fillMaxSize() - .drawBehind { - drawRect( - brush = Brush.horizontalGradient( - 0.0f to bgColor.copy(alpha = 0.96f), - 0.10f to bgColor.copy(alpha = 0.72f), - 0.30f to Color.Transparent - ), - size = size - ) - drawRect( - brush = Brush.radialGradient( - colorStops = arrayOf( - 0.0f to bgColor.copy(alpha = 0.78f), - 0.55f to bgColor.copy(alpha = 0.52f), - 0.80f to bgColor.copy(alpha = 0.16f), - 1.0f to Color.Transparent - ), - center = Offset(0f, size.height / 2f), - radius = size.height * 1.0f - ), - size = size - ) - } - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0.78f to Color.Transparent, - 0.90f to bgColor.copy(alpha = 0.72f), - 0.96f to bgColor.copy(alpha = 0.98f), - 1.0f to bgColor - ) - ) - ) - } + ModernHeroMediaLayer( + heroBackdrop = heroBackdrop, + heroBackdropAlpha = heroBackdropAlpha, + shouldPlayHeroTrailer = shouldPlayHeroTrailer, + heroTrailerUrl = heroTrailerUrl, + heroTrailerAlpha = heroTrailerAlpha, + muted = uiState.focusedPosterBackdropTrailerMuted, + bgColor = bgColor, + onTrailerEnded = { expandedCatalogFocusKey = null }, + onFirstFrameRendered = { heroTrailerFirstFrameRendered = true }, + modifier = heroMediaModifier, + requestWidthPx = heroMediaWidthPx, + requestHeightPx = heroMediaHeightPx + ) val leftGradient = remember(bgColor) { Brush.horizontalGradient( colorStops = arrayOf( @@ -653,235 +861,10 @@ fun ModernHomeContent( }, label = "modernActiveRowContent" ) { activeRowStateKey -> - val row = carouselRows.firstOrNull { it.key == activeRowStateKey } - - Column { - Text( - text = row?.title.orEmpty(), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = NuvioColors.TextPrimary, - modifier = Modifier.padding(start = rowHorizontalPadding, bottom = activeRowTitleBottom) - ) - - row?.let { resolvedRow -> - val rowListState = rowListStates.getOrPut(resolvedRow.key) { - LazyListState( - firstVisibleItemIndex = focusState.catalogRowScrollStates[resolvedRow.key] ?: 0 - ) - } - - LaunchedEffect(resolvedRow.key, pendingRowFocusKey, pendingRowFocusIndex) { - if (pendingRowFocusKey != resolvedRow.key) return@LaunchedEffect - val targetIndex = (pendingRowFocusIndex ?: 0) - .coerceIn(0, (resolvedRow.items.size - 1).coerceAtLeast(0)) - val requester = requesterFor(resolvedRow.key, targetIndex) - var didFocus = false - var didScrollToTarget = false - repeat(20) { - didFocus = runCatching { - requester.requestFocus() - true - }.getOrDefault(false) - if (didFocus) { - return@repeat - } - if (!didScrollToTarget) { - val targetVisible = rowListState.layoutInfo.visibleItemsInfo - .any { it.index == targetIndex } - if (!targetVisible) { - runCatching { rowListState.scrollToItem(targetIndex) } - didScrollToTarget = true - } - } - withFrameNanos { } - } - if (!didFocus) { - val fallbackIndex = rowListState.layoutInfo.visibleItemsInfo - .firstOrNull() - ?.index - ?.coerceIn(0, (resolvedRow.items.size - 1).coerceAtLeast(0)) - ?: 0 - didFocus = runCatching { - requesterFor(resolvedRow.key, fallbackIndex).requestFocus() - true - }.getOrDefault(false) - } - if (didFocus) { - pendingRowFocusKey = null - pendingRowFocusIndex = null - } - } - - LaunchedEffect( - resolvedRow.key, - rowListState, - resolvedRow.items.size, - resolvedRow.supportsSkip, - resolvedRow.hasMore, - resolvedRow.isLoading - ) { - snapshotFlow { - val lastVisible = rowListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val total = rowListState.layoutInfo.totalItemsCount - lastVisible to total - } - .distinctUntilChanged() - .collect { (lastVisible, total) -> - if (total <= 0) return@collect - val catalogId = resolvedRow.catalogId - val addonId = resolvedRow.addonId - val apiType = resolvedRow.apiType - if (lastVisible >= total - 4 && - resolvedRow.supportsSkip && - resolvedRow.hasMore && - !resolvedRow.isLoading && - !catalogId.isNullOrBlank() && - !addonId.isNullOrBlank() && - !apiType.isNullOrBlank() - ) { - onLoadMoreCatalog(catalogId, addonId, apiType) - } - } - } - - LazyRow( - state = rowListState, - modifier = Modifier.focusRestorer { - val restoreIndex = (focusedItemByRow[resolvedRow.key] ?: 0) - .coerceIn(0, (resolvedRow.items.size - 1).coerceAtLeast(0)) - requesterFor(resolvedRow.key, restoreIndex) - }, - contentPadding = PaddingValues(horizontal = rowHorizontalPadding), - horizontalArrangement = Arrangement.spacedBy(rowItemSpacing) - ) { - itemsIndexed( - items = resolvedRow.items, - key = { index, item -> "${resolvedRow.key}_${item.key}_$index" } - ) { index, item -> - val requester = requesterFor(resolvedRow.key, index) - val onFocused = { - focusedItemByRow[resolvedRow.key] = index - activeRowKey = resolvedRow.key - heroItem = item.heroPreview - if (resolvedRow.key == "continue_watching") { - lastFocusedContinueWatchingIndex = index - focusedCatalogSelection = null - } - } - when (val payload = item.payload) { - is ModernPayload.ContinueWatching -> { - ContinueWatchingCard( - item = payload.item, - onClick = { onContinueWatchingClick(payload.item) }, - onLongPress = { optionsItem = payload.item }, - cardWidth = continueWatchingCardWidth, - imageHeight = continueWatchingCardHeight, - modifier = Modifier - .focusRequester(requester) - .onFocusChanged { - if (it.isFocused) { - onFocused() - } - } - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (event.key) { - Key.DirectionUp -> moveToRow(-1) - Key.DirectionDown -> moveToRow(1) - else -> false - } - } - ) - } - is ModernPayload.Catalog -> { - val focusKey = "${resolvedRow.key}::${payload.itemId}" - val suppressCardExpansionForHeroTrailer = - effectiveAutoplayEnabled && - trailerPlaybackTarget == - FocusedPosterTrailerPlaybackTarget.HERO_MEDIA - val isBackdropExpanded = - effectiveExpandEnabled && - expandedCatalogFocusKey == focusKey && - !suppressCardExpansionForHeroTrailer - val trailerPreviewUrl = trailerPreviewUrls[payload.itemId] - val playTrailerInExpandedCard = - effectiveAutoplayEnabled && - trailerPlaybackTarget == - FocusedPosterTrailerPlaybackTarget.EXPANDED_CARD && - isBackdropExpanded - ModernCarouselCard( - item = item, - useLandscapePosters = useLandscapePosters, - showLabels = uiState.posterLabelsEnabled, - cardCornerRadius = cardCornerRadius, - cardWidth = activeCardWidth, - cardHeight = activeCardHeight, - focusedPosterBackdropExpandEnabled = effectiveExpandEnabled, - isBackdropExpanded = isBackdropExpanded, - playTrailerInExpandedCard = playTrailerInExpandedCard, - focusedPosterBackdropTrailerMuted = uiState.focusedPosterBackdropTrailerMuted, - trailerPreviewUrl = trailerPreviewUrl, - focusRequester = requester, - onFocused = { - onFocused() - focusedCatalogSelection = FocusedCatalogSelection( - focusKey = focusKey, - payload = payload - ) - }, - onClick = { - onNavigateToDetail( - payload.itemId, - payload.itemType, - payload.addonBaseUrl - ) - }, - onMoveUp = { moveToRow(-1) }, - onMoveDown = { moveToRow(1) }, - onBackdropInteraction = { - expansionInteractionNonce++ - }, - onTrailerEnded = { - expandedCatalogFocusKey = null - } - ) - } - } - } - } - - if (showNextRowPreview) { - val rowIndex = rowIndexByKey[resolvedRow.key] ?: -1 - val previewRow = carouselRows.getOrNull(rowIndex + 1) - if (previewRow != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .height(previewVisibleHeight) - .clipToBounds() - ) { - LazyRow( - userScrollEnabled = false, - contentPadding = PaddingValues(horizontal = rowHorizontalPadding), - horizontalArrangement = Arrangement.spacedBy(rowItemSpacing) - ) { - itemsIndexed( - previewRow.items.take(12), - key = { index, item -> "${previewRow.key}_${item.key}_$index" } - ) { _, item -> - PreviewCarouselCard( - imageUrl = item.imageUrl ?: item.heroPreview.poster ?: item.heroPreview.backdrop, - cardWidth = previewCardWidth, - cardHeight = previewCardHeight - ) - } - } - } - } - } - } - } + ModernActiveRowContent( + activeRowStateKey = activeRowStateKey, + activeRowTitleBottom = activeRowTitleBottom + ) } } } @@ -921,7 +904,6 @@ fun ModernHomeContent( } private fun buildContinueWatchingItem( - index: Int, item: ContinueWatchingItem, useLandscapePosters: Boolean ): ModernCarouselItem { @@ -982,7 +964,7 @@ private fun buildContinueWatchingItem( } return ModernCarouselItem( - key = "cw_${index}_$imageUrl", + key = continueWatchingItemKey(item), title = when (item) { is ContinueWatchingItem.InProgress -> item.progress.name is ContinueWatchingItem.NextUp -> item.info.name @@ -998,10 +980,10 @@ private fun buildContinueWatchingItem( } private fun buildCatalogItem( - index: Int, item: MetaPreview, row: CatalogRow, - useLandscapePosters: Boolean + useLandscapePosters: Boolean, + occurrence: Int ): ModernCarouselItem { val heroPreview = HeroPreview( title = item.name, @@ -1021,7 +1003,7 @@ private fun buildCatalogItem( ) return ModernCarouselItem( - key = "catalog_${row.key()}_${item.id}_$index", + key = "catalog_${row.key()}_${item.id}_${occurrence}", title = item.name, subtitle = item.releaseInfo, imageUrl = if (useLandscapePosters) { @@ -1041,6 +1023,15 @@ private fun buildCatalogItem( ) } +private fun continueWatchingItemKey(item: ContinueWatchingItem): String { + return when (item) { + is ContinueWatchingItem.InProgress -> + "cw_inprogress_${item.progress.contentId}_${item.progress.videoId}_${item.progress.season ?: -1}_${item.progress.episode ?: -1}" + is ContinueWatchingItem.NextUp -> + "cw_nextup_${item.info.contentId}_${item.info.videoId}_${item.info.season}_${item.info.episode}" + } +} + private fun catalogRowKey(row: CatalogRow): String { return "${row.addonId}_${row.apiType}_${row.catalogId}" } @@ -1121,6 +1112,8 @@ private fun ModernCarouselCard( onTrailerEnded: () -> Unit ) { val cardShape = RoundedCornerShape(cardCornerRadius) + val context = LocalContext.current + val density = LocalDensity.current val landscapeLogoGradient = remember { Brush.verticalGradient( colorStops = arrayOf( @@ -1145,6 +1138,33 @@ private fun ModernCarouselCard( } else { item.imageUrl ?: item.heroPreview.poster ?: item.heroPreview.backdrop } + val requestWidthPx = remember(targetCardWidth, density) { + with(density) { targetCardWidth.roundToPx() } + } + val requestHeightPx = remember(cardHeight, density) { + with(density) { cardHeight.roundToPx() } + } + val imageModel = remember(context, imageUrl, requestWidthPx, requestHeightPx) { + ImageRequest.Builder(context) + .data(imageUrl) + .crossfade(false) + .size(width = requestWidthPx, height = requestHeightPx) + .build() + } + val logoHeight = cardHeight * 0.34f + val logoHeightPx = remember(logoHeight, density) { + with(density) { logoHeight.roundToPx() } + } + val logoModel = remember(context, item.heroPreview.logo, requestWidthPx, logoHeightPx) { + item.heroPreview.logo?.let { + ImageRequest.Builder(context) + .data(it) + .crossfade(false) + .size(width = requestWidthPx, height = logoHeightPx) + .build() + } + } + val shouldPlayTrailerInCard = playTrailerInExpandedCard && !trailerPreviewUrl.isNullOrBlank() Column( modifier = Modifier.width(animatedCardWidth), @@ -1187,13 +1207,13 @@ private fun ModernCarouselCard( ) { Box(modifier = Modifier.fillMaxSize()) { AsyncImage( - model = imageUrl, + model = imageModel, contentDescription = item.title, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) - if (playTrailerInExpandedCard) { + if (shouldPlayTrailerInCard) { TrailerPlayer( trailerUrl = trailerPreviewUrl, isPlaying = true, @@ -1212,7 +1232,7 @@ private fun ModernCarouselCard( .background(landscapeLogoGradient) ) AsyncImage( - model = item.heroPreview.logo, + model = logoModel, contentDescription = item.title, modifier = Modifier .align(Alignment.BottomStart) @@ -1254,12 +1274,125 @@ private fun ModernCarouselCard( } } +@Composable +private fun ModernHeroMediaLayer( + heroBackdrop: String?, + heroBackdropAlpha: Float, + shouldPlayHeroTrailer: Boolean, + heroTrailerUrl: String?, + heroTrailerAlpha: Float, + muted: Boolean, + bgColor: Color, + onTrailerEnded: () -> Unit, + onFirstFrameRendered: () -> Unit, + modifier: Modifier, + requestWidthPx: Int, + requestHeightPx: Int +) { + val localContext = LocalContext.current + Box(modifier = modifier) { + Crossfade( + targetState = heroBackdrop, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = heroBackdropAlpha }, + animationSpec = tween(durationMillis = 350), + label = "modernHeroBackground" + ) { imageUrl -> + val imageModel = remember(localContext, imageUrl, requestWidthPx, requestHeightPx) { + ImageRequest.Builder(localContext) + .data(imageUrl) + .crossfade(false) + .size(width = requestWidthPx, height = requestHeightPx) + .build() + } + AsyncImage( + model = imageModel, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + alignment = Alignment.TopEnd + ) + } + + if (shouldPlayHeroTrailer) { + TrailerPlayer( + trailerUrl = heroTrailerUrl, + isPlaying = true, + onEnded = onTrailerEnded, + onFirstFrameRendered = onFirstFrameRendered, + muted = muted, + cropToFill = true, + overscanZoom = MODERN_TRAILER_OVERSCAN_ZOOM, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = heroTrailerAlpha } + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .drawBehind { + drawRect( + brush = Brush.horizontalGradient( + 0.0f to bgColor.copy(alpha = 0.96f), + 0.10f to bgColor.copy(alpha = 0.72f), + 0.30f to Color.Transparent + ), + size = size + ) + drawRect( + brush = Brush.radialGradient( + colorStops = arrayOf( + 0.0f to bgColor.copy(alpha = 0.78f), + 0.55f to bgColor.copy(alpha = 0.52f), + 0.80f to bgColor.copy(alpha = 0.16f), + 1.0f to Color.Transparent + ), + center = Offset(0f, size.height / 2f), + radius = size.height * 1.0f + ), + size = size + ) + } + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0.78f to Color.Transparent, + 0.90f to bgColor.copy(alpha = 0.72f), + 0.96f to bgColor.copy(alpha = 0.98f), + 1.0f to bgColor + ) + ) + ) + } +} + @Composable private fun PreviewCarouselCard( imageUrl: String?, cardWidth: Dp, cardHeight: Dp ) { + val context = LocalContext.current + val density = LocalDensity.current + val requestWidthPx = remember(cardWidth, density) { + with(density) { cardWidth.roundToPx() } + } + val requestHeightPx = remember(cardHeight, density) { + with(density) { cardHeight.roundToPx() } + } + val imageModel = remember(context, imageUrl, requestWidthPx, requestHeightPx) { + ImageRequest.Builder(context) + .data(imageUrl) + .crossfade(false) + .size(width = requestWidthPx, height = requestHeightPx) + .build() + } Box( modifier = Modifier .width(cardWidth) @@ -1267,7 +1400,7 @@ private fun PreviewCarouselCard( .clip(RoundedCornerShape(10.dp)) ) { AsyncImage( - model = imageUrl, + model = imageModel, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop @@ -1280,6 +1413,43 @@ private fun PreviewCarouselCard( } } +@Composable +private fun ModernNextRowPreviewStrip( + previewRow: HeroCarouselRow?, + rowHorizontalPadding: Dp, + rowItemSpacing: Dp, + previewVisibleHeight: Dp, + previewCardWidth: Dp, + previewCardHeight: Dp +) { + if (previewRow == null) return + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .height(previewVisibleHeight) + .clipToBounds() + ) { + LazyRow( + userScrollEnabled = false, + contentPadding = PaddingValues(horizontal = rowHorizontalPadding), + horizontalArrangement = Arrangement.spacedBy(rowItemSpacing) + ) { + itemsIndexed( + previewRow.items.take(12), + key = { _, item -> item.key }, + contentType = { _, _ -> "modern_preview_card" } + ) { _, item -> + PreviewCarouselCard( + imageUrl = item.imageUrl ?: item.heroPreview.poster ?: item.heroPreview.backdrop, + cardWidth = previewCardWidth, + cardHeight = previewCardHeight + ) + } + } + } +} + private fun shouldResetBackdropTimer(key: Key): Boolean { return when (key) { Key.DirectionUp, @@ -1308,6 +1478,18 @@ private fun HeroTitleBlock( val titleScale = if (portraitMode) 0.92f else 1f val metaScale = if (portraitMode && shouldRenderPreviewRow) 0.90f else 1f val context = LocalContext.current + val density = LocalDensity.current + val logoMaxWidthPx = remember(density) { with(density) { 220.dp.roundToPx() } } + val logoHeightPx = remember(density) { with(density) { 100.dp.roundToPx() } } + val logoModel = remember(context, preview.logo, logoMaxWidthPx, logoHeightPx) { + preview.logo?.let { + ImageRequest.Builder(context) + .data(it) + .crossfade(false) + .size(width = logoMaxWidthPx, height = logoHeightPx) + .build() + } + } Column( modifier = modifier, @@ -1315,7 +1497,7 @@ private fun HeroTitleBlock( ) { if (!preview.logo.isNullOrBlank()) { AsyncImage( - model = preview.logo, + model = logoModel, contentDescription = preview.title, modifier = Modifier .height(100.dp) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index 613af17c..c8f32070 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.atomic.AtomicLong class PlayerRuntimeController( internal val context: Context, @@ -128,6 +129,7 @@ class PlayerRuntimeController( internal var frameRateProbeToken: Long = 0L internal var hideAspectRatioIndicatorJob: Job? = null internal var hideStreamSourceIndicatorJob: Job? = null + internal var hideSubtitleDelayOverlayJob: Job? = null internal var nextEpisodeAutoPlayJob: Job? = null internal var sourceStreamsJob: Job? = null internal var sourceStreamsCacheRequestKey: String? = null @@ -173,6 +175,7 @@ class PlayerRuntimeController( internal var pauseOverlayJob: Job? = null internal val pauseOverlayDelayMs = 5000L internal val seekProgressSyncDebounceMs = 700L + internal val subtitleDelayUs = AtomicLong(0L) internal var pendingPreviewSeekPosition: Long? = null internal var pendingResumeProgress: WatchProgress? = null internal var hasRetriedCurrentStreamAfter416: Boolean = false diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt index f1721352..b6e11153 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt @@ -14,7 +14,10 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.ForwardingRenderer +import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory @@ -103,7 +106,11 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map< .setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE) - val renderersFactory = DefaultRenderersFactory(context) + subtitleDelayUs.set(_uiState.value.subtitleDelayMs.toLong() * 1000L) + val renderersFactory = SubtitleOffsetRenderersFactory( + context = context, + subtitleDelayUsProvider = subtitleDelayUs::get + ) .setExtensionRendererMode(playerSettings.decoderPriority) .setMapDV7ToHevc(playerSettings.mapDV7ToHevc) @@ -318,3 +325,34 @@ internal fun PlayerRuntimeController.resetLoadingOverlayForNewStream() { ) } } + +private class SubtitleOffsetRenderersFactory( + context: Context, + private val subtitleDelayUsProvider: () -> Long +) : DefaultRenderersFactory(context) { + + override fun buildTextRenderers( + context: Context, + output: TextOutput, + outputLooper: android.os.Looper, + extensionRendererMode: Int, + out: ArrayList + ) { + val startIndex = out.size + super.buildTextRenderers(context, output, outputLooper, extensionRendererMode, out) + for (index in startIndex until out.size) { + out[index] = SubtitleOffsetRenderer(out[index], subtitleDelayUsProvider) + } + } +} + +private class SubtitleOffsetRenderer( + renderer: Renderer, + private val subtitleDelayUsProvider: () -> Long +) : ForwardingRenderer(renderer) { + + override fun render(positionUs: Long, elapsedRealtimeUs: Long) { + val adjustedPositionUs = (positionUs - subtitleDelayUsProvider()).coerceAtLeast(0L) + super.render(adjustedPositionUs, elapsedRealtimeUs) + } +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt index ae187811..e5b2253c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt @@ -26,6 +26,7 @@ internal fun PlayerRuntimeController.releasePlayer() { seekProgressSyncJob?.cancel() frameRateProbeJob?.cancel() hideStreamSourceIndicatorJob?.cancel() + hideSubtitleDelayOverlayJob?.cancel() nextEpisodeAutoPlayJob?.cancel() nextEpisodeAutoPlayJob = null _exoPlayer?.release() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt index 7aa6bc1c..01b53667 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt @@ -254,12 +254,60 @@ fun PlayerRuntimeController.scheduleHideControls() { if (_uiState.value.isPlaying && !_uiState.value.showAudioDialog && !_uiState.value.showSubtitleDialog && !_uiState.value.showSubtitleStylePanel && !_uiState.value.showSpeedDialog && !_uiState.value.showMoreDialog && + !_uiState.value.showSubtitleDelayOverlay && !_uiState.value.showEpisodesPanel && !_uiState.value.showSourcesPanel) { _uiState.update { it.copy(showControls = false) } } } } +internal fun PlayerRuntimeController.showSubtitleDelayOverlay() { + hideControlsJob?.cancel() + _uiState.update { + it.copy( + showControls = false, + showSubtitleDelayOverlay = true, + showAudioDialog = false, + showSubtitleDialog = false, + showSubtitleStylePanel = false, + showSpeedDialog = false + ) + } + scheduleHideSubtitleDelayOverlay() +} + +internal fun PlayerRuntimeController.hideSubtitleDelayOverlay() { + hideSubtitleDelayOverlayJob?.cancel() + hideSubtitleDelayOverlayJob = null + _uiState.update { it.copy(showSubtitleDelayOverlay = false) } +} + +internal fun PlayerRuntimeController.adjustSubtitleDelay(deltaMs: Int) { + val currentDelayMs = _uiState.value.subtitleDelayMs + val newDelayMs = (currentDelayMs + deltaMs).coerceIn( + minimumValue = SUBTITLE_DELAY_MIN_MS, + maximumValue = SUBTITLE_DELAY_MAX_MS + ) + + subtitleDelayUs.set(newDelayMs.toLong() * 1000L) + _uiState.update { + it.copy( + subtitleDelayMs = newDelayMs, + showControls = false, + showSubtitleDelayOverlay = true + ) + } + scheduleHideSubtitleDelayOverlay() +} + +internal fun PlayerRuntimeController.scheduleHideSubtitleDelayOverlay() { + hideSubtitleDelayOverlayJob?.cancel() + hideSubtitleDelayOverlayJob = scope.launch { + delay(SUBTITLE_DELAY_OVERLAY_TIMEOUT_MS) + _uiState.update { it.copy(showSubtitleDelayOverlay = false) } + } +} + internal fun PlayerRuntimeController.schedulePauseOverlay() { pauseOverlayJob?.cancel() @@ -378,7 +426,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { } is PlayerEvent.OnSelectAudioTrack -> { selectAudioTrack(event.index) - _uiState.update { it.copy(showAudioDialog = false) } + _uiState.update { it.copy(showAudioDialog = false, showSubtitleDelayOverlay = false) } } is PlayerEvent.OnSelectSubtitleTrack -> { autoSubtitleSelected = true @@ -390,6 +438,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { it.copy( showSubtitleDialog = false, showSubtitleStylePanel = false, + showSubtitleDelayOverlay = false, selectedAddonSubtitle = null ) } @@ -404,6 +453,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { it.copy( showSubtitleDialog = false, showSubtitleStylePanel = false, + showSubtitleDelayOverlay = false, selectedAddonSubtitle = null, selectedSubtitleTrackIndex = -1 ) @@ -412,15 +462,28 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { is PlayerEvent.OnSelectAddonSubtitle -> { autoSubtitleSelected = true selectAddonSubtitle(event.subtitle) - _uiState.update { it.copy(showSubtitleDialog = false, showSubtitleStylePanel = false) } + _uiState.update { + it.copy( + showSubtitleDialog = false, + showSubtitleStylePanel = false, + showSubtitleDelayOverlay = false + ) + } } is PlayerEvent.OnSetPlaybackSpeed -> { _exoPlayer?.setPlaybackSpeed(event.speed) _uiState.update { - it.copy(playbackSpeed = event.speed, showSpeedDialog = false) + it.copy( + playbackSpeed = event.speed, + showSpeedDialog = false, + showSubtitleDelayOverlay = false + ) } } PlayerEvent.OnToggleControls -> { + if (_uiState.value.showSubtitleDelayOverlay) { + hideSubtitleDelayOverlay() + } _uiState.update { it.copy(showControls = !it.showControls) } if (_uiState.value.showControls) { scheduleHideControls() @@ -432,6 +495,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { showAudioDialog = true, showSubtitleStylePanel = false, showMoreDialog = false, + showSubtitleDelayOverlay = false, showControls = true ) } @@ -442,6 +506,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { showSubtitleDialog = true, showSubtitleStylePanel = false, showMoreDialog = false, + showSubtitleDelayOverlay = false, showControls = true ) } @@ -452,6 +517,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { showSubtitleDialog = false, showSubtitleStylePanel = true, showMoreDialog = false, + showSubtitleDelayOverlay = false, showControls = true ) } @@ -460,12 +526,22 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { _uiState.update { it.copy(showSubtitleStylePanel = false) } scheduleHideControls() } + PlayerEvent.OnShowSubtitleDelayOverlay -> { + showSubtitleDelayOverlay() + } + PlayerEvent.OnHideSubtitleDelayOverlay -> { + hideSubtitleDelayOverlay() + } + is PlayerEvent.OnAdjustSubtitleDelay -> { + adjustSubtitleDelay(event.deltaMs) + } PlayerEvent.OnShowSpeedDialog -> { _uiState.update { it.copy( showSpeedDialog = true, showSubtitleStylePanel = false, showMoreDialog = false, + showSubtitleDelayOverlay = false, showControls = true ) } @@ -477,6 +553,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { showAudioDialog = false, showSubtitleDialog = false, showSubtitleStylePanel = false, + showSubtitleDelayOverlay = false, showSpeedDialog = false, showControls = true ) @@ -537,6 +614,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { showSubtitleDialog = false, showSubtitleStylePanel = false, showSpeedDialog = false, + showSubtitleDelayOverlay = false, showMoreDialog = false ) } @@ -549,7 +627,8 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) { _uiState.update { state -> state.copy( error = null, - showLoadingOverlay = state.loadingOverlayEnabled + showLoadingOverlay = state.loadingOverlayEnabled, + showSubtitleDelayOverlay = false ) } releasePlayer() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index e9ed695e..b6ff2219 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -66,6 +68,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -97,6 +100,7 @@ import com.nuvio.tv.R import com.nuvio.tv.core.player.ExternalPlayerLauncher import com.nuvio.tv.ui.components.LoadingIndicator import com.nuvio.tv.ui.theme.NuvioColors +import java.util.Locale import java.util.concurrent.TimeUnit import kotlinx.coroutines.launch @@ -123,6 +127,8 @@ fun PlayerScreen( viewModel.onEvent(PlayerEvent.OnDismissPauseOverlay) } else if (uiState.showMoreDialog) { viewModel.onEvent(PlayerEvent.OnDismissMoreDialog) + } else if (uiState.showSubtitleDelayOverlay) { + viewModel.onEvent(PlayerEvent.OnHideSubtitleDelayOverlay) } else if (uiState.showSubtitleStylePanel) { viewModel.onEvent(PlayerEvent.OnDismissSubtitleStylePanel) } else if (uiState.showSourcesPanel) { @@ -266,6 +272,7 @@ fun PlayerScreen( uiState.showEpisodesPanel, uiState.showSourcesPanel, uiState.showSubtitleStylePanel, + uiState.showSubtitleDelayOverlay, uiState.showAudioDialog, uiState.showSubtitleDialog, uiState.showSpeedDialog, @@ -273,8 +280,8 @@ fun PlayerScreen( ) { if (uiState.showControls && !uiState.showEpisodesPanel && !uiState.showSourcesPanel && !uiState.showAudioDialog && !uiState.showSubtitleDialog && - !uiState.showSubtitleStylePanel && !uiState.showSpeedDialog && - !uiState.showMoreDialog + !uiState.showSubtitleStylePanel && !uiState.showSubtitleDelayOverlay && + !uiState.showSpeedDialog && !uiState.showMoreDialog ) { // Wait for AnimatedVisibility animation to complete before focusing play/pause button kotlinx.coroutines.delay(250) @@ -309,12 +316,63 @@ fun PlayerScreen( .background(Color.Black) .focusRequester(containerFocusRequester) .focusable() + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.nativeKeyEvent.keyCode != KeyEvent.KEYCODE_CAPTIONS) { + return@onPreviewKeyEvent false + } + + if (keyEvent.nativeKeyEvent.action != KeyEvent.ACTION_UP) { + return@onPreviewKeyEvent true + } + + if (uiState.showSubtitleDelayOverlay) { + viewModel.onEvent(PlayerEvent.OnHideSubtitleDelayOverlay) + } else if ( + !uiState.showEpisodesPanel && + !uiState.showSourcesPanel && + !uiState.showAudioDialog && + !uiState.showSubtitleDialog && + !uiState.showSubtitleStylePanel && + !uiState.showSpeedDialog + ) { + viewModel.onEvent(PlayerEvent.OnShowSubtitleDialog) + } + true + } .onKeyEvent { keyEvent -> + if (uiState.showSubtitleDelayOverlay) { + if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { + when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + viewModel.onEvent(PlayerEvent.OnAdjustSubtitleDelay(-SUBTITLE_DELAY_STEP_MS)) + return@onKeyEvent true + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + viewModel.onEvent(PlayerEvent.OnAdjustSubtitleDelay(SUBTITLE_DELAY_STEP_MS)) + return@onKeyEvent true + } + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN -> { + viewModel.onEvent(PlayerEvent.OnHideSubtitleDelayOverlay) + return@onKeyEvent true + } + } + } + if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP && + (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT || + keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) + ) { + return@onKeyEvent true + } + } + // When a side panel or dialog is open, let it handle all keys val panelOrDialogOpen = uiState.showEpisodesPanel || uiState.showSourcesPanel || uiState.showAudioDialog || uiState.showSubtitleDialog || uiState.showSubtitleStylePanel || uiState.showSpeedDialog || - uiState.showMoreDialog + uiState.showSubtitleDelayOverlay || uiState.showMoreDialog if (panelOrDialogOpen) return@onKeyEvent false if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_UP) { @@ -558,6 +616,7 @@ fun PlayerScreen( !uiState.showAudioDialog && !uiState.showSubtitleDialog && !uiState.showSubtitleStylePanel && + !uiState.showSubtitleDelayOverlay && !uiState.showSpeedDialog && !uiState.showMoreDialog, controlsVisible = uiState.showControls, @@ -600,6 +659,7 @@ fun PlayerScreen( visible = uiState.showControls && uiState.error == null && !uiState.showLoadingOverlay && !uiState.showPauseOverlay && !uiState.showSubtitleStylePanel && + !uiState.showSubtitleDelayOverlay && !uiState.showEpisodesPanel && !uiState.showSourcesPanel && !uiState.showAudioDialog && @@ -654,10 +714,34 @@ fun PlayerScreen( } // Seek-only overlay (progress bar + time) when controls are hidden + AnimatedVisibility( + visible = uiState.showSubtitleDelayOverlay && + !uiState.showControls && + uiState.error == null && + !uiState.showLoadingOverlay && + !uiState.showPauseOverlay && + !uiState.showSubtitleStylePanel && + !uiState.showEpisodesPanel && + !uiState.showSourcesPanel && + !uiState.showAudioDialog && + !uiState.showSubtitleDialog && + !uiState.showSpeedDialog, + enter = fadeIn(animationSpec = tween(120)), + exit = fadeOut(animationSpec = tween(120)), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 44.dp) + .zIndex(2.3f) + ) { + SubtitleDelayOverlay( + subtitleDelayMs = uiState.subtitleDelayMs + ) + } + AnimatedVisibility( visible = uiState.showSeekOverlay && !uiState.showControls && uiState.error == null && !uiState.showLoadingOverlay && !uiState.showPauseOverlay && - !uiState.showMoreDialog, + !uiState.showSubtitleDelayOverlay && !uiState.showMoreDialog, enter = fadeIn(animationSpec = tween(150)), exit = fadeOut(animationSpec = tween(150)), modifier = Modifier.align(Alignment.BottomCenter) @@ -807,6 +891,7 @@ fun PlayerScreen( onAddonSubtitleSelected = { viewModel.onEvent(PlayerEvent.OnSelectAddonSubtitle(it)) }, onDisableSubtitles = { viewModel.onEvent(PlayerEvent.OnDisableSubtitles) }, onOpenStylePanel = { viewModel.onEvent(PlayerEvent.OnOpenSubtitleStylePanel) }, + onOpenDelayOverlay = { viewModel.onEvent(PlayerEvent.OnShowSubtitleDelayOverlay) }, onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) } ) } @@ -1265,6 +1350,91 @@ private fun StreamSourceIndicator(text: String) { } } +@Composable +private fun SubtitleDelayOverlay(subtitleDelayMs: Int) { + val fraction = ((subtitleDelayMs - SUBTITLE_DELAY_MIN_MS).toFloat() / + (SUBTITLE_DELAY_MAX_MS - SUBTITLE_DELAY_MIN_MS).toFloat()).coerceIn(0f, 1f) + + Column( + modifier = Modifier + .fillMaxWidth(0.6f) + .clip(RoundedCornerShape(26.dp)) + .background( + Brush.linearGradient( + colors = listOf( + Color(0xCC1F3246), + Color(0xCC283655), + Color(0xCC2F2B55) + ) + ) + ) + .padding(horizontal = 26.dp, vertical = 20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Subtitle offset", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = Color.White + ) + Text( + text = formatSubtitleDelay(subtitleDelayMs), + style = MaterialTheme.typography.titleLarge, + color = Color.White.copy(alpha = 0.95f) + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + ) { + val thumbWidth = 22.dp + val thumbOffset = (maxWidth - thumbWidth) * fraction + + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .align(Alignment.CenterStart) + .background(Color.White.copy(alpha = 0.15f)) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(5) { index -> + val tickHeight = if (index == 2) 13.dp else 9.dp + Box( + modifier = Modifier + .width(1.dp) + .height(tickHeight) + .background(Color.White.copy(alpha = 0.22f)) + ) + } + } + + Box( + modifier = Modifier + .offset(x = thumbOffset) + .align(Alignment.CenterStart) + .width(thumbWidth) + .height(8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.95f)) + ) + } + } +} + @Composable private fun rememberRawSvgPainter(@RawRes iconRes: Int): Painter { val context = LocalContext.current @@ -1527,3 +1697,7 @@ private fun formatTime(millis: Long): String { String.format("%d:%02d", minutes, seconds) } } + +private fun formatSubtitleDelay(delayMs: Int): String { + return String.format(Locale.US, "%+.3fs", delayMs / 1000f) +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt index a0e14ce1..fc18797d 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt @@ -41,6 +41,8 @@ data class PlayerUiState( val showAudioDialog: Boolean = false, val showSubtitleDialog: Boolean = false, val showSubtitleStylePanel: Boolean = false, + val showSubtitleDelayOverlay: Boolean = false, + val subtitleDelayMs: Int = 0, val showSpeedDialog: Boolean = false, val showMoreDialog: Boolean = false, // Subtitle style settings @@ -156,6 +158,9 @@ sealed class PlayerEvent { data object OnShowSubtitleDialog : PlayerEvent() data object OnOpenSubtitleStylePanel : PlayerEvent() data object OnDismissSubtitleStylePanel : PlayerEvent() + data object OnShowSubtitleDelayOverlay : PlayerEvent() + data object OnHideSubtitleDelayOverlay : PlayerEvent() + data class OnAdjustSubtitleDelay(val deltaMs: Int) : PlayerEvent() data object OnShowSpeedDialog : PlayerEvent() data object OnShowMoreDialog : PlayerEvent() data object OnDismissMoreDialog : PlayerEvent() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDelayConfig.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDelayConfig.kt new file mode 100644 index 00000000..4f8be3e2 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDelayConfig.kt @@ -0,0 +1,6 @@ +package com.nuvio.tv.ui.screens.player + +internal const val SUBTITLE_DELAY_MIN_MS = -60_000 +internal const val SUBTITLE_DELAY_MAX_MS = 60_000 +internal const val SUBTITLE_DELAY_STEP_MS = 100 +internal const val SUBTITLE_DELAY_OVERLAY_TIMEOUT_MS = 5_000L diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDialog.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDialog.kt index f8e7e305..05b67fe5 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDialog.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/SubtitleDialog.kt @@ -90,6 +90,7 @@ internal fun SubtitleSelectionDialog( onAddonSubtitleSelected: (Subtitle) -> Unit, onDisableSubtitles: () -> Unit, onOpenStylePanel: () -> Unit, + onOpenDelayOverlay: () -> Unit, onDismiss: () -> Unit ) { var selectedTabIndex by remember { mutableIntStateOf(0) } @@ -104,7 +105,7 @@ internal fun SubtitleSelectionDialog( SubtitleOrganizationMode.NONE, SubtitleOrganizationMode.BY_ADDON -> "Addons" } - val tabs = listOf("Built-in", addonsTabTitle, "Style") + val tabs = listOf("Built-in", addonsTabTitle, "Style", "Delay") val tabFocusRequesters = remember { tabs.map { FocusRequester() } } Dialog(onDismissRequest = onDismiss) { @@ -132,13 +133,15 @@ internal fun SubtitleSelectionDialog( .padding(bottom = 16.dp) ) { tabs.forEachIndexed { index, _ -> - val onTabClick = if (index == 2) { - { - onOpenStylePanel() + val onTabClick = when (index) { + 2 -> { + { onOpenStylePanel() } } - } else { - { - selectedTabIndex = index + 3 -> { + { onOpenDelayOverlay() } + } + else -> { + { selectedTabIndex = index } } } SubtitleTab( @@ -173,6 +176,7 @@ internal fun SubtitleSelectionDialog( onSubtitleSelected = onAddonSubtitleSelected ) 2 -> Unit + 3 -> Unit } } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt index 5c7dd2bc..709ad91c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt @@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable @@ -34,9 +37,6 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.tv.foundation.lazy.grid.TvGridCells -import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid -import androidx.tv.foundation.lazy.grid.itemsIndexed import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -84,8 +84,8 @@ fun ThemeSettingsContent( .fillMaxWidth() .weight(1f) ) { - TvLazyVerticalGrid( - columns = TvGridCells.Fixed(3), + LazyVerticalGrid( + columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(6.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44421bfe..1ace5318 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,13 +2,13 @@ agp = "8.13.2" cryptoJs = "4.2.0" kotlin = "2.3.0" -coreKtx = "1.10.1" +coreKtx = "1.15.0" appcompat = "1.6.1" composeBom = "2025.01.01" tvFoundation = "1.0.0-alpha11" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.8.7" -activityCompose = "1.9.3" +activityCompose = "1.10.1" # New dependencies hilt = "2.58" @@ -18,7 +18,7 @@ okhttp = "4.12.0" moshi = "1.15.1" coroutines = "1.8.1" coil = "2.7.0" -navigationCompose = "2.7.7" +navigationCompose = "2.8.8" datastore = "1.1.1" lifecycleViewModelCompose = "2.8.7" ksp = "2.3.0" @@ -31,7 +31,7 @@ nanohttpd = "2.3.1" zxing = "3.5.3" supabase = "3.1.4" ktor = "3.1.1" -kotlinxSerialization = "1.7.3" +kotlinxSerialization = "1.8.0" benchmarkMacro = "1.4.1" androidxTestExtJunit = "1.2.1" androidxTestRunner = "1.6.2"