diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index ae03e62ef..1e6052004 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -307,6 +307,8 @@ class CloudSyncRepository @Inject constructor( val trailerSoundEnabled: Boolean = false, val trailerDelaySeconds: Int = 1, val trailerInCards: Boolean = true, + // Null from older clients: keep the local preference. + val netflixHomeStyle: Boolean? = null, val clockFormat: String = "24h", val guideRowCount: Int? = null, val showBudget: Boolean = true, @@ -345,6 +347,8 @@ class CloudSyncRepository @Inject constructor( profileManager.profileBooleanKeyFor(profileId, "trailer_sound_enabled") private fun trailerInCardsKeyFor(profileId: String) = profileManager.profileBooleanKeyFor(profileId, "trailer_in_cards") + private fun netflixHomeStyleKeyFor(profileId: String) = + profileManager.profileBooleanKeyFor(profileId, com.arflix.tv.ui.screens.home.NETFLIX_HOME_STYLE_KEY) private fun trailerDelayKeyFor(profileId: String) = profileManager.profileStringKeyFor(profileId, "trailer_delay_seconds") private fun clockFormatKeyFor(profileId: String) = @@ -688,6 +692,7 @@ class CloudSyncRepository @Inject constructor( trailerSoundEnabled = prefs[trailerSoundEnabledKeyFor(profile.id)] ?: false, trailerDelaySeconds = prefs[trailerDelayKeyFor(profile.id)]?.toIntOrNull() ?: 1, trailerInCards = prefs[trailerInCardsKeyFor(profile.id)] ?: true, + netflixHomeStyle = prefs[netflixHomeStyleKeyFor(profile.id)] ?: true, clockFormat = prefs[clockFormatKeyFor(profile.id)] ?: "24h", guideRowCount = prefs[guideRowCountKeyFor(profile.id)]?.toIntOrNull() ?.takeIf { it in 6..10 } ?: 0, @@ -1621,6 +1626,7 @@ class CloudSyncRepository @Inject constructor( prefs[trailerSoundEnabledKeyFor(profileId)] = state.trailerSoundEnabled prefs[trailerDelayKeyFor(profileId)] = state.trailerDelaySeconds.toString() prefs[trailerInCardsKeyFor(profileId)] = state.trailerInCards + state.netflixHomeStyle?.let { prefs[netflixHomeStyleKeyFor(profileId)] = it } prefs[clockFormatKeyFor(profileId)] = state.clockFormat // Older clients omit this field; keep the local preference in that case. state.guideRowCount?.let { count -> diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferences.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferences.kt index b1ca6500f..ea587b105 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferences.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferences.kt @@ -9,11 +9,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +/** Per-profile toggle for the Netflix-style TV Home (billboard + expanding row cards). */ +internal const val NETFLIX_HOME_STYLE_KEY = "netflix_home_style" + internal data class HomeProfilePreferences( val trailerAutoPlay: Boolean, val trailerSoundEnabled: Boolean, val trailerDelaySeconds: Int, val trailerInCards: Boolean, + val netflixHomeStyle: Boolean, val showBudget: Boolean, val iptvFavoritesOnHome: Boolean, val clockFormat: String, @@ -33,6 +37,7 @@ internal fun readHomeProfilePreferences( trailerDelaySeconds = preferences[stringPreferencesKey("${prefix}trailer_delay_seconds")] ?.toIntOrNull() ?: 1, trailerInCards = preferences[booleanPreferencesKey("${prefix}trailer_in_cards")] ?: true, + netflixHomeStyle = preferences[booleanPreferencesKey("$prefix$NETFLIX_HOME_STYLE_KEY")] ?: true, showBudget = preferences[booleanPreferencesKey("${prefix}show_budget_on_home")] ?: true, iptvFavoritesOnHome = preferences[ booleanPreferencesKey("$prefix$IPTV_FAVORITES_ON_HOME") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index 2003122a9..82cda7195 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -228,7 +228,7 @@ private object HomeRegexes { val WHITESPACE = Regex("\\s+") } -private fun Context.cleanOverviewText(value: String): String { +internal fun Context.cleanOverviewText(value: String): String { return value .replace(HomeRegexes.HTML_TAG, " ") .replace(HomeRegexes.NON_BREAKING_SPACE, " ") @@ -240,7 +240,7 @@ private fun Context.cleanOverviewText(value: String): String { // Genre ID to display name (TMDB standard). The numeric id stays the key; // only the rendered label is localized — see `TmdbGenreNames.kt`. -private fun Context.genreNames(mediaType: MediaType, genreIds: List): List = +internal fun Context.genreNames(mediaType: MediaType, genreIds: List): List = genreIds.mapNotNull { id -> val res = if (mediaType == MediaType.TV) tvGenreNameRes(id) else movieGenreNameRes(id) res?.let { getString(it) } @@ -253,6 +253,10 @@ internal class HomeFocusState( initialSidebarIndex: Int = 1 ) { var isSidebarFocused by mutableStateOf(false) + // Netflix-style Home: the billboard holds focus. It stays set while the top bar is + // focused from the billboard, so Down returns there instead of to a row. + var isHeroFocused by mutableStateOf(false) + var heroButtonIndex by mutableIntStateOf(0) var sidebarFocusIndex by mutableIntStateOf(initialSidebarIndex) var currentRowIndex by mutableIntStateOf(initialRowIndex) var currentItemIndex by mutableIntStateOf(initialItemIndex) @@ -333,7 +337,8 @@ internal class HomeFocusState( it.preferredCategoryId.orEmpty(), HashMap(it.rowItemIndicesByCategoryId), HashMap(it.rowItemKeysByCategoryId), - it.isSidebarFocused + it.isSidebarFocused, + it.isHeroFocused ) }, restore = { @@ -347,6 +352,7 @@ internal class HomeFocusState( if (key is String && value is String) rowItemKeysByCategoryId[key] = value } isSidebarFocused = it.getOrNull(7) as? Boolean ?: false + isHeroFocused = it.getOrNull(8) as? Boolean ?: false } } ) @@ -414,7 +420,7 @@ private fun getFocusedItem(categories: List, rowIndex: Int, itemIndex: internal fun navigableHomeItems(items: List): List = if (items.any { it.isPlaceholder } && items.any { !it.isPlaceholder }) items.filterNot { it.isPlaceholder } else items -private fun homeRowItemKey(item: MediaItem): String { +internal fun homeRowItemKey(item: MediaItem): String { val episodeSuffix = item.nextEpisode?.let { "_S${it.seasonNumber}E${it.episodeNumber}" }.orEmpty() return "${item.mediaType.name}-${item.id}$episodeSuffix" } @@ -498,6 +504,7 @@ internal fun homeRowViewAllIndex(category: Category, hasMore: Boolean): Int = @androidx.compose.runtime.Immutable private data class HomeFocusedHeroSnapshot( + val heroFocused: Boolean, val rowIndex: Int, val itemIndex: Int, val focusedItemKey: String, @@ -516,7 +523,7 @@ private fun preferredHomeStartRowIndex(categories: List): Int { return 0 } -private fun isActionableHomeItem(item: MediaItem?): Boolean { +internal fun isActionableHomeItem(item: MediaItem?): Boolean { return item != null && item.hasOpenableId && !item.isPlaceholder } @@ -603,6 +610,34 @@ private suspend fun androidx.compose.foundation.lazy.LazyListState.animateHomeSc +/** The hero preview player (channels, service videos): no controls, never focusable. */ +@Composable +internal fun HomeHeroVideoSurface(player: ExoPlayer, modifier: Modifier = Modifier) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + useController = false + setControllerAutoShow(false) + hideController() + isFocusable = false + isFocusableInTouchMode = false + descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + setShutterBackgroundColor(android.graphics.Color.TRANSPARENT) + setKeepContentOnPlayerReset(true) + this.player = player + } + }, + update = { pv -> + pv.useController = false + pv.setControllerAutoShow(false) + pv.hideController() + pv.player = player + }, + modifier = modifier + ) +} + @Composable private fun HomeBackdropCrossfade( backdropUrl: String?, @@ -869,6 +904,46 @@ fun HomeScreen( // Use rememberSaveable to persist focus position across navigation (back from details page) val focusState = rememberSaveable(currentProfile?.id, saver = HomeFocusState.Saver) { HomeFocusState() } + // Netflix-style Home (TV only): a billboard above the rows, and the focused card + // of the focused row expands with its description below it. + val netflixHome = !isMobile && uiState.netflixHomeStyle + LaunchedEffect(netflixHome) { + if (!netflixHome) { + focusState.isHeroFocused = false + } else if (!focusState.userHasNavigated) { + focusState.isHeroFocused = true + focusState.heroButtonIndex = 0 + } + } + // A random title per Home visit; saved so returning from Details keeps it. + var billboardKey by rememberSaveable(currentProfile?.id) { mutableStateOf(null) } + var billboardItem by remember(currentProfile?.id) { mutableStateOf(null) } + LaunchedEffect(netflixHome, displayCategories) { + if (!netflixHome) return@LaunchedEffect + val candidates = netflixBillboardCandidates(displayCategories) { item -> + viewModel.isIptvItem(item) || viewModel.isCollectionItem(item) || viewModel.isSportsHomeItem(item) + } + if (candidates.isEmpty()) return@LaunchedEffect + val current = billboardKey?.let { key -> candidates.firstOrNull { homeRowItemKey(it) == key } } + if (current != null) { + billboardItem = current + } else if (billboardItem == null || !focusState.userHasNavigated) { + // Before the first key press a better source (Top 10 loading after + // Trending) may still replace the pick. + val picked = candidates.random() + billboardKey = homeRowItemKey(picked) + billboardItem = picked + } + } + // While the billboard is focused it is the ViewModel's hero, which loads its + // details, logo and trailer exactly as for a focused card. + val vmHeroKey = uiState.heroItem?.let { homeRowItemKey(it) } + LaunchedEffect(netflixHome, focusState.isHeroFocused, billboardKey, billboardItem != null, vmHeroKey) { + val item = billboardItem ?: return@LaunchedEffect + if (netflixHome && focusState.isHeroFocused && !uiState.heroItem.isSameHomeHero(item)) { + viewModel.updateHeroItem(item) + } + } val fastScrollThresholdMs = 650L // How long the D-pad must sit still before the hero starts a live IPTV preview. // This gates only the IPTV branch of heroVideoUrl (collection MP4s skip it), so it is @@ -936,12 +1011,19 @@ fun HomeScreen( !viewModel.isSportsHomeItem(focusedHomeItem) val focusedHomeTrailerKey = uiState.heroTrailerKey.takeIf { !isMobile && uiState.trailerAutoPlay && !showContextMenu && !focusState.isSidebarFocused && + !focusState.isHeroFocused && focusedCategory?.id != "continue_watching" && focusedHomeItem != null && uiState.heroItem.isSameHomeHero(focusedHomeItem) && displayHeroItem.isSameHomeHero(focusedHomeItem) && !viewModel.isIptvItem(focusedHomeItem) && !viewModel.isCollectionItem(focusedHomeItem) && !viewModel.isSportsHomeItem(focusedHomeItem) } + val billboardTrailerKey = uiState.heroTrailerKey.takeIf { + // Only while the billboard itself has focus: the top bar above it stops the trailer. + netflixHome && uiState.trailerAutoPlay && !showContextMenu && focusState.isHeroFocused && + !focusState.isSidebarFocused && billboardItem != null && uiState.heroItem.isSameHomeHero(billboardItem) + } + BackHandler(enabled = showContextMenu) { showContextMenu = false contextMenuItem = null @@ -987,6 +1069,7 @@ fun HomeScreen( ?.items ?.getOrNull(focusState.currentItemIndex) HomeFocusedHeroSnapshot( + heroFocused = focusState.isHeroFocused, rowIndex = focusState.currentRowIndex, itemIndex = focusState.currentItemIndex, // Include the exact focused item key so async row updates, especially @@ -1000,7 +1083,7 @@ fun HomeScreen( .distinctUntilChanged() .collectLatest { focusSnapshot -> val categoriesSnapshot = latestDisplayCategories - if (categoriesSnapshot.isEmpty() || focusState.isSidebarFocused) return@collectLatest + if (categoriesSnapshot.isEmpty() || focusState.isSidebarFocused || focusSnapshot.heroFocused) return@collectLatest if (focusSnapshot.focusedItemKey.isBlank()) return@collectLatest if (focusSnapshot.focusedItemKey == focusSnapshot.heroItemKey) return@collectLatest categoriesSnapshot.getOrNull(focusSnapshot.rowIndex) @@ -1015,7 +1098,8 @@ fun HomeScreen( if ( focusState.currentRowIndex != focusSnapshot.rowIndex || focusState.currentItemIndex != focusSnapshot.itemIndex || - focusState.isSidebarFocused + focusState.isSidebarFocused || + focusState.isHeroFocused ) { return@collectLatest } @@ -1083,6 +1167,7 @@ fun HomeScreen( ?.takeIf { heroVideoAllowed && isHeroCollection && collectionVideoFinishedId != it.id } ?.let { viewModel.getCollectionHeroVideoUrl(it) } val heroVideoUrl = when { + netflixHome && focusState.isHeroFocused -> null !isMobile && !focusState.userHasNavigated -> null !heroVideoAllowed -> null !isHomeStarted -> null @@ -1237,7 +1322,7 @@ fun HomeScreen( } // On mobile, the hero backdrop is rendered inline inside MobileHomeRowsLayer — skip the fixed backdrop. // On TV, fill the entire screen with the backdrop. - if (!isMobile) { + if (!isMobile && !netflixHome) { val backdropModifier = Modifier.fillMaxSize() Box(modifier = backdropModifier) { if (!showCinematicHomeLayer || settledBackdrop == null) { @@ -1259,27 +1344,8 @@ fun HomeScreen( } if (heroExoPlayer != null && (heroVideoUrl != null || heroVideoAlpha > 0.01f)) { - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - useController = false - setControllerAutoShow(false) - hideController() - isFocusable = false - isFocusableInTouchMode = false - descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM - setShutterBackgroundColor(android.graphics.Color.TRANSPARENT) - setKeepContentOnPlayerReset(true) - player = heroExoPlayer - } - }, - update = { pv -> - pv.useController = false - pv.setControllerAutoShow(false) - pv.hideController() - pv.player = heroExoPlayer - }, + HomeHeroVideoSurface( + player = heroExoPlayer, modifier = Modifier .fillMaxSize() .graphicsLayer { alpha = heroVideoAlpha } @@ -1357,6 +1423,30 @@ fun HomeScreen( } } // end if (!isMobile) backdrop + // Netflix mode has no backdrop, but a focused service card still previews behind + // the rows, as on the classic Home. Channels preview inside their expanded card. + if (netflixHome && !isHeroIptv && heroExoPlayer != null && (heroVideoUrl != null || heroVideoAlpha > 0.01f)) { + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = heroVideoAlpha } + ) { + HomeHeroVideoSurface(player = heroExoPlayer, modifier = Modifier.fillMaxSize()) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.75f), + 0.35f to Color.Black.copy(alpha = 0.35f), + 0.7f to Color.Black.copy(alpha = 0.55f), + 1f to Color.Black.copy(alpha = 0.9f) + ) + ) + ) + } + } + Box(modifier = Modifier.fillMaxSize()) { HomeInputLayer( categories = displayCategories, @@ -1425,6 +1515,33 @@ fun HomeScreen( onSwitchProfile = onSwitchProfile, onExitApp = onExitApp, featuredTrailerKey = if (uiState.trailerInCards) focusedHomeTrailerKey else null, + netflix = if (netflixHome) { + val billboard = billboardItem?.let { item -> + uiState.heroItem?.takeIf { it.isSameHomeHero(item) } ?: item + } + NetflixHomeContent( + billboardItem = billboard, + billboardLogoUrl = billboard?.let { item -> + uiState.heroLogoUrl?.takeIf { uiState.heroItem.isSameHomeHero(item) } + ?: cardLogoUrls["${item.mediaType}_${item.id}"] + }, + billboardTrailerKey = billboardTrailerKey, + featuredItem = uiState.heroItem, + cardTrailerKey = focusedHomeTrailerKey, + cardTrailerDelayMs = uiState.trailerDelaySeconds * 1000L, + trailerVolume = if (uiState.trailerSoundEnabled) 1f else 0f, + featuredOverview = displayHeroOverview, + channelPreviewPlayer = heroExoPlayer?.takeIf { isHeroIptv }, + channelPreviewItem = displayHeroItem?.takeIf { isHeroIptv } + ) + } else null, + onBillboardOpen = { item, play -> + navigateToDetailsWithCache( + item.mediaType, item.id, + item.nextEpisode?.seasonNumber.takeIf { play }, + item.nextEpisode?.episodeNumber.takeIf { play } + ) + }, reserveTrailerSpace = reserveTrailerSpace, featuredTrailerDelayMs = uiState.trailerDelaySeconds * 1000L, featuredTrailerVolume = if (uiState.trailerSoundEnabled) 1f else 0f, @@ -1436,7 +1553,7 @@ fun HomeScreen( ) } // end trailer-dim wrapper - if (showCinematicHomeLayer) { + if (showCinematicHomeLayer && !netflixHome) { Box(modifier = Modifier.fillMaxSize()) { HomeHeroLayer( heroItem = displayHeroItem, @@ -1456,7 +1573,7 @@ fun HomeScreen( // A clean 16:9 hero slot, beside the metadata and above the rails. // The official player cannot sit behind our gradients, logos or cards. - if (!uiState.trailerInCards && focusedHomeTrailerKey != null && heroVideoUrl == null) { + if (!netflixHome && !uiState.trailerInCards && focusedHomeTrailerKey != null && heroVideoUrl == null) { val rowsHeight = if (configuration.screenHeightDp < 600) 238.dp else (configuration.screenHeightDp.dp * 0.35f).coerceIn(260.dp, 340.dp) // Leave room above the rail without landing exactly on the 200dp @@ -1980,7 +2097,7 @@ private fun imdbRatingFor(item: MediaItem): String { } @Composable -private fun TopRankRibbon( +internal fun TopRankRibbon( rank: Int, isFocused: Boolean, compact: Boolean, @@ -2589,6 +2706,8 @@ internal fun HomeInputLayer( reserveTrailerSpace: Boolean = featuredTrailerKey != null, featuredTrailerDelayMs: Long = 0L, featuredTrailerVolume: Float = 0f, + netflix: NetflixHomeContent? = null, + onBillboardOpen: (item: MediaItem, play: Boolean) -> Unit = { _, _ -> }, onOpenContextMenu: (MediaItem, Boolean) -> Unit, ) { val focusRequester = remember { FocusRequester() } @@ -2653,7 +2772,12 @@ internal fun HomeInputLayer( categories.getOrNull(focusState.currentRowIndex)?.id?.let { categoryId -> focusState.rowItemIndicesByCategoryId[categoryId] = focusState.currentItemIndex } - focusState.isSidebarFocused = true + if (netflix?.billboardItem != null && !focusState.isHeroFocused) { + focusState.isHeroFocused = true + focusState.heroButtonIndex = 0 + } else { + focusState.isSidebarFocused = true + } } } @@ -2728,6 +2852,21 @@ internal fun HomeInputLayer( // Reconcile against this render's data before applying new input, not in // an asynchronous effect after a background update and key press race. focusState.reconcile(categories, categoryHasMoreMap) + if (netflix != null) { + handleNetflixHomeKey( + event = event, + netflix = netflix, + focusState = focusState, + categories = categories, + isRtl = isRtl, + onBillboardOpen = onBillboardOpen, + onOpenContextMenu = onOpenContextMenu + )?.let { netflixHandled -> + selectPressedInHome = false + selectDownAtMs = 0L + return@onPreviewKeyEvent netflixHandled + } + } val handled = when (event.type) { KeyEventType.KeyDown -> when (event.key) { Key.Enter, Key.DirectionCenter -> { @@ -2982,6 +3121,11 @@ internal fun HomeInputLayer( reserveTrailerSpace = reserveTrailerSpace, featuredTrailerDelayMs = featuredTrailerDelayMs, featuredTrailerVolume = featuredTrailerVolume, + netflix = netflix, + // Channels expand like titles (their live preview plays in the card). + isSpecialItem = { item -> + isSportsHomeItem(item) || item.status?.startsWith("collection:") == true + }, onItemClick = { item -> if (!isActionableHomeItem(item)) { return@HomeRowsLayer @@ -3049,10 +3193,26 @@ private fun HomeRowsLayer( reserveTrailerSpace: Boolean = featuredTrailerKey != null, featuredTrailerDelayMs: Long = 0L, featuredTrailerVolume: Float = 0f, + netflix: NetflixHomeContent? = null, + isSpecialItem: (MediaItem) -> Boolean = { false }, onItemClick: (MediaItem) -> Unit, onItemLongClick: ((MediaItem, Boolean) -> Unit)? = null ) { - if (isMobile) { + if (!isMobile && netflix != null) { + NetflixTvHomeRowsLayer( + categories = categories, + cardLogoUrls = cardLogoUrls, + focusState = focusState, + contentStartPadding = contentStartPadding, + categoryHasMoreMap = categoryHasMoreMap, + smoothScrolling = smoothScrolling, + onLoadMoreCategory = onLoadMoreCategory, + onViewAllCategory = onViewAllCategory, + netflix = netflix, + isSpecialItem = isSpecialItem, + onItemClick = onItemClick + ) + } else if (isMobile) { MobileHomeRowsLayer( categories = categories, cardLogoUrls = cardLogoUrls, @@ -3590,7 +3750,7 @@ private fun TvHomeRowsLayer( /** Trailing card of a home row that opens the row's full grid ("View all"). */ @Composable -private fun HomeViewAllCard( +internal fun HomeViewAllCard( width: Dp, isLandscape: Boolean, isFocused: Boolean, @@ -3785,7 +3945,7 @@ private fun ImdbBadge(rating: String) { @OptIn(ExperimentalTvMaterial3Api::class) @Composable -private fun ContentRow( +internal fun ContentRow( category: Category, cardLogoUrls: Map, isCurrentRow: Boolean, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 7037a02a9..363ed443e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -105,6 +105,7 @@ data class HomeUiState( val trailerSoundEnabled: Boolean = false, val trailerDelaySeconds: Int = 1, val trailerInCards: Boolean = true, + val netflixHomeStyle: Boolean = true, // Home hero metadata visibility toggles (issue #72) val showBudget: Boolean = true, val heroOverviewOverride: String? = null, @@ -2059,6 +2060,7 @@ class HomeViewModel @Inject constructor( trailerSoundEnabled = preferences.trailerSoundEnabled, trailerDelaySeconds = preferences.trailerDelaySeconds, trailerInCards = preferences.trailerInCards, + netflixHomeStyle = preferences.netflixHomeStyle, showBudget = preferences.showBudget, clockFormat = preferences.clockFormat, smoothScrolling = preferences.smoothScrolling diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/NetflixHome.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/NetflixHome.kt new file mode 100644 index 000000000..7f33de8ac --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/NetflixHome.kt @@ -0,0 +1,1125 @@ +package com.arflix.tv.ui.screens.home + +import android.os.SystemClock +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import coil.request.ImageRequest +import coil.size.Precision +import com.arflix.tv.R +import com.arflix.tv.data.model.Category +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.model.isPortrait +import com.arflix.tv.ui.components.AppTopBarHeight +import com.arflix.tv.ui.components.CardLayoutMode +import com.arflix.tv.ui.components.HomeTrailerPreview +import com.arflix.tv.ui.components.MediaCard +import com.arflix.tv.ui.components.rememberCatalogueRowLayoutMode +import com.arflix.tv.ui.theme.ArflixTypography +import com.arflix.tv.util.TmdbImageSizing +import com.arflix.tv.util.parseRatingValue +import com.arflix.tv.ui.components.ImdbSvgRatingBadge +import coil.imageLoader +import kotlin.math.abs +import kotlin.math.roundToInt +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.LayoutCoordinates + +/** What the Netflix-style TV Home draws beyond the classic rows. Null selects the classic Home. */ +@Immutable +internal data class NetflixHomeContent( + val billboardItem: MediaItem?, + val billboardLogoUrl: String?, + val billboardTrailerKey: String?, + /** The ViewModel hero: the focused card with its loaded details (overview, runtime...). */ + val featuredItem: MediaItem?, + val cardTrailerKey: String?, + val cardTrailerDelayMs: Long, + val trailerVolume: Float, + /** The ViewModel hero's overview; for a channel, the programme on air. */ + val featuredOverview: String? = null, + /** + * The hero preview player while a channel is the hero ([channelPreviewItem]). Channel + * cards share one surface on it: many IPTV streams rarely send keyframes, so moving the + * decoder to a new surface would leave the video black under playing audio. + */ + val channelPreviewPlayer: androidx.media3.exoplayer.ExoPlayer? = null, + val channelPreviewItem: MediaItem? = null +) + +private val NETFLIX_BILLBOARD_TOP10_IDS = setOf("top10_movies_today", "top10_shows_today") +private val NETFLIX_BILLBOARD_TRENDING_IDS = setOf("trending_movies", "trending_tv") + +/** + * Billboard candidates: today's Top 10 movies and series. When those rows are hidden, + * any other Top 10 row, then the trending rows. + */ +internal fun netflixBillboardCandidates( + categories: List, + isSpecialItem: (MediaItem) -> Boolean +): List { + fun itemsOf(rows: List) = rows.asSequence() + .flatMap { it.items.asSequence() } + .filter { isActionableHomeItem(it) && !isSpecialItem(it) && !it.backdrop.isNullOrBlank() } + .distinctBy { homeRowItemKey(it) } + .toList() + + val isOtherTop10 = { category: Category -> + category.id.contains("top10", ignoreCase = true) || + category.title.contains("Top 10", ignoreCase = true) + } + return sequenceOf<(Category) -> Boolean>( + { it.id in NETFLIX_BILLBOARD_TOP10_IDS }, + isOtherTop10, + { it.id in NETFLIX_BILLBOARD_TRENDING_IDS } + ) + .map { matches -> itemsOf(categories.filter(matches)) } + .firstOrNull { it.isNotEmpty() } + .orEmpty() +} + +/** + * D-pad handling the Netflix layout adds on top of the classic Home: the billboard is a + * focus stop between the top bar and the first row. Returns null for keys it leaves to + * the classic handler. + */ +internal fun handleNetflixHomeKey( + event: KeyEvent, + netflix: NetflixHomeContent, + focusState: HomeFocusState, + categories: List, + isRtl: Boolean, + onBillboardOpen: (MediaItem, Boolean) -> Unit, + onOpenContextMenu: (MediaItem, Boolean) -> Unit +): Boolean? { + val keyDown = event.type == KeyEventType.KeyDown + val isSelect = event.key == Key.Enter || event.key == Key.DirectionCenter + val isBack = event.key == Key.Back || event.key == Key.Escape + val now = SystemClock.elapsedRealtime() + val billboardFocused = focusState.isHeroFocused && !focusState.isSidebarFocused + + if (billboardFocused) { + if (!keyDown) return if (isSelect) true else null + return when (event.key) { + Key.DirectionLeft, Key.DirectionRight -> { + val forward = (event.key == Key.DirectionRight) != isRtl + focusState.heroButtonIndex = if (forward) 1 else 0 + focusState.userHasNavigated = true + focusState.lastNavEventTime = now + true + } + Key.DirectionUp -> { + focusState.userHasNavigated = true + focusState.isSidebarFocused = true + focusState.lastNavEventTime = now + true + } + Key.DirectionDown -> { + val firstRow = categories.firstOrNull() ?: return true + focusState.userHasNavigated = true + focusState.isHeroFocused = false + focusState.currentRowIndex = 0 + focusState.currentItemIndex = focusState.restoredItemIndex( + firstRow, + focusState.rowItemIndicesByCategoryId[firstRow.id] ?: 0 + ) + focusState.lastNavEventTime = now + true + } + Key.Enter, Key.DirectionCenter -> { + if (event.nativeKeyEvent.repeatCount == 0) { + netflix.billboardItem?.takeIf { isActionableHomeItem(it) } + ?.let { onBillboardOpen(it, focusState.heroButtonIndex == 0) } + } + true + } + Key.Menu, Key.Info -> { + netflix.billboardItem?.takeIf { isActionableHomeItem(it) } + ?.let { onOpenContextMenu(it, false) } + true + } + Key.Back, Key.Escape -> { + focusState.isSidebarFocused = true + true + } + else -> null + } + } + + if (!keyDown) return null + + // The top bar above the billboard: Down returns to the billboard, not a row. + if (focusState.isSidebarFocused && focusState.isHeroFocused && event.key == Key.DirectionDown) { + focusState.isSidebarFocused = false + focusState.lastNavEventTime = now + return true + } + + // A row: Up from the first row and Back both scroll back to the billboard. + val rowFocused = !focusState.isSidebarFocused && !focusState.isHeroFocused + val toBillboard = (event.key == Key.DirectionUp && focusState.currentRowIndex == 0) || isBack + if (rowFocused && toBillboard && netflix.billboardItem != null) { + categories.getOrNull(focusState.currentRowIndex)?.id?.let { categoryId -> + focusState.rowItemIndicesByCategoryId[categoryId] = focusState.currentItemIndex + } + focusState.recordSelection(categories) + focusState.userHasNavigated = true + focusState.isHeroFocused = true + focusState.heroButtonIndex = 0 + focusState.lastNavEventTime = now + return true + } + return null +} + +/** TV rows for the Netflix layout: the billboard scrolls with the rows as their first item. */ +@Composable +internal fun NetflixTvHomeRowsLayer( + categories: List, + cardLogoUrls: Map, + focusState: HomeFocusState, + contentStartPadding: Dp, + categoryHasMoreMap: Map, + smoothScrolling: Boolean, + onLoadMoreCategory: (String) -> Unit, + onViewAllCategory: (String) -> Unit, + netflix: NetflixHomeContent, + isSpecialItem: (MediaItem) -> Boolean, + onItemClick: (MediaItem) -> Unit +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val screenHeight = maxHeight + val topInset = AppTopBarHeight + 6.dp + // About 70% of the screen, leaving the first row's title and the top of + // its cards peeking below it. + val billboardHeight = (maxHeight - topInset - 70.dp).coerceAtLeast(200.dp) + // Focused-row card height: room for the row title above, the description + // below and a peek of the next row. + val bigCardHeight = (maxHeight * 0.42f).coerceIn(170.dp, 330.dp) + val listState = rememberLazyListState() + val targetIndex = if (focusState.isHeroFocused) 0 else focusState.currentRowIndex + 1 + val focusedRowId = if (focusState.isHeroFocused) null else categories.getOrNull(focusState.currentRowIndex)?.id + var appliedTarget by remember { mutableIntStateOf(-1) } + LaunchedEffect(targetIndex, focusedRowId) { + if (appliedTarget < 0 || abs(targetIndex - listState.firstVisibleItemIndex) > 4) { + listState.scrollToItem(targetIndex) + } else { + listState.animateScrollToItem(targetIndex) + } + appliedTarget = targetIndex + } + // Rows inserted or removed above the focused one (late catalog / channel loads) + // shift the list under a keyed LazyColumn: re-anchor to the focused row. + LaunchedEffect(categories) { + if (appliedTarget >= 0 && !listState.isScrollInProgress && + listState.firstVisibleItemIndex != targetIndex + ) { + listState.scrollToItem(targetIndex) + } + } + + // Where the focused channel card's media area is, relative to this layer. + var layerCoordinates by remember { mutableStateOf(null) } + var channelMediaBounds by remember { mutableStateOf(null) } + val onChannelCardPlaced: (LayoutCoordinates) -> Unit = { card -> + layerCoordinates?.takeIf { it.isAttached && card.isAttached }?.let { layer -> + channelMediaBounds = layer.localBoundingBoxOf(card, clipBounds = false) + } + } + val focusedChannel = if (!focusState.isSidebarFocused && !focusState.isHeroFocused) { + categories.getOrNull(focusState.currentRowIndex) + ?.let { row -> navigableHomeItems(row.items).getOrNull(focusState.currentItemIndex) } + ?.takeIf { it.isIptvChannel() } + } else null + + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = topInset) + .clipToBounds() + .onGloballyPositioned { layerCoordinates = it } + ) { + LazyColumn( + state = listState, + userScrollEnabled = false, + // Lets the last row scroll up to the top like every other row. + contentPadding = PaddingValues(bottom = screenHeight), + modifier = Modifier.fillMaxSize() + ) { + item(key = "netflix_billboard", contentType = "netflix_billboard") { + NetflixBillboard( + item = netflix.billboardItem, + logoUrl = netflix.billboardLogoUrl, + trailerKey = netflix.billboardTrailerKey, + trailerVolume = netflix.trailerVolume, + isFocused = focusState.isHeroFocused && !focusState.isSidebarFocused, + focusedButton = focusState.heroButtonIndex, + horizontalPadding = contentStartPadding, + modifier = Modifier + .fillMaxWidth() + .height(billboardHeight) + ) + Spacer(Modifier.height(14.dp)) + } + itemsIndexed( + items = categories, + key = { _, category -> stableHomeRowKey("netflix", category.id) }, + contentType = { _, category -> + if (netflixRowIsSpecial(category, isSpecialItem)) "netflix_special_row" else "netflix_row" + } + ) { rowIndex, category -> + val rowIsFocused = !focusState.isSidebarFocused && !focusState.isHeroFocused && + rowIndex == focusState.currentRowIndex + val rowHasMore = categoryHasMoreMap[category.id] == true + val viewAllIndex = remember(category.items, rowHasMore) { + homeRowViewAllIndex(category, rowHasMore) + } + val focusedItemIndex = when { + !rowIsFocused -> -1 + viewAllIndex >= 0 && focusState.currentItemIndex == viewAllIndex -> viewAllIndex + else -> clampHomeItemIndex(category.items, focusState.currentItemIndex) + } + val onRowLoadMore = remember(category.id) { { onLoadMoreCategory(category.id) } } + val onRowViewAll = remember(category.id) { { onViewAllCategory(category.id) } } + val rowUsePosterCards = + rememberCatalogueRowLayoutMode("home:${category.id}") == CardLayoutMode.POSTER + if (netflixRowIsSpecial(category, isSpecialItem)) { + // Channel, service and sports tiles have no backdrop/overview to + // expand into: they keep the classic rail. + Box( + modifier = Modifier + .fillMaxWidth() + .height(if (category.isPortrait(rowUsePosterCards)) 245.dp else 202.dp) + .clipToBounds() + ) { + ContentRow( + category = category, + cardLogoUrls = cardLogoUrls, + isCurrentRow = rowIsFocused, + usePosterCards = rowUsePosterCards, + startPadding = contentStartPadding, + categoryHasMore = rowHasMore, + smoothScrolling = smoothScrolling, + onLoadMore = onRowLoadMore, + showViewAll = viewAllIndex >= 0, + onViewAll = onRowViewAll, + focusedItemIndex = focusedItemIndex, + isFastScrolling = false, + onItemClick = onItemClick, + onItemFocused = { _, _ -> } + ) + } + } else { + NetflixContentRow( + category = category, + cardLogoUrls = cardLogoUrls, + isCurrentRow = rowIsFocused, + focusedItemIndex = focusedItemIndex, + restingItemIndex = focusState.rowItemIndicesByCategoryId[category.id] ?: 0, + usePosterCards = rowUsePosterCards, + bigCardHeight = bigCardHeight, + startPadding = contentStartPadding, + categoryHasMore = rowHasMore, + showViewAll = viewAllIndex >= 0, + featuredItem = netflix.featuredItem, + featuredOverview = netflix.featuredOverview, + onChannelCardPlaced = onChannelCardPlaced, + trailerKey = if (rowIsFocused) netflix.cardTrailerKey else null, + trailerDelayMs = netflix.cardTrailerDelayMs, + trailerVolume = netflix.trailerVolume, + onLoadMore = onRowLoadMore, + onViewAll = onRowViewAll, + onItemClick = onItemClick + ) + } + } + } + // One surface for every channel card (see NetflixHomeContent.channelPreviewPlayer): + // it follows focus along the row instead of each card creating its own. + val previewPlayer = netflix.channelPreviewPlayer + val bounds = channelMediaBounds + if (focusedChannel != null && previewPlayer != null && bounds != null) { + val density = LocalDensity.current + NetflixChannelPreview( + player = previewPlayer, + focusedItem = focusedChannel, + streamItem = netflix.channelPreviewItem, + modifier = Modifier + // Measured bounds are physical (from the left edge); anchor there in RTL too. + .align(AbsoluteAlignment.TopLeft) + .absoluteOffset { IntOffset(bounds.left.roundToInt(), bounds.top.roundToInt()) } + .size(with(density) { bounds.width.toDp() }, with(density) { bounds.height.toDp() }) + ) + } + } + } +} + +private fun netflixRowIsSpecial(category: Category, isSpecialItem: (MediaItem) -> Boolean): Boolean = + category.id.startsWith("collection_row_") || + category.items.firstOrNull { !it.isPlaceholder }?.let(isSpecialItem) == true + +@Composable +private fun NetflixBillboard( + item: MediaItem?, + logoUrl: String?, + trailerKey: String?, + trailerVolume: Float, + isFocused: Boolean, + focusedButton: Int, + horizontalPadding: Dp, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val density = LocalDensity.current + val shape = RoundedCornerShape(16.dp) + val borderAlpha by animateFloatAsState( + targetValue = if (isFocused) 0.85f else 0.12f, + animationSpec = tween(160), + label = "netflix-billboard-border" + ) + Box(modifier = modifier.padding(horizontal = horizontalPadding).testTag("netflix_billboard")) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .border(2.dp, Color.White.copy(alpha = borderAlpha), shape) + // The border stays outside the media, which must not be covered. + .padding(3.dp) + .clip(RoundedCornerShape(13.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + // The trailer and artwork fill the width at 16:9; the billboard crops them. + val mediaWidth = maxWidth + val mediaHeight = maxOf(maxHeight, maxWidth * (9f / 16f)) + val backdropRequest = remember(item?.backdrop, item?.image, mediaWidth, mediaHeight) { + val url = (item?.backdrop ?: item?.image)?.takeIf { it.isNotBlank() } + url?.let { + val widthPx = with(density) { mediaWidth.roundToPx() }.coerceAtLeast(1) + val heightPx = with(density) { mediaHeight.roundToPx() }.coerceAtLeast(1) + ImageRequest.Builder(context) + .data(TmdbImageSizing.forSlot(it, widthPx, heightPx, TmdbImageSizing.Kind.BACKDROP)) + .size(widthPx, heightPx) + .precision(Precision.INEXACT) + .crossfade(250) + .build() + } + } + HomeTrailerPreview( + youtubeKey = trailerKey, + delayMs = 0L, + volume = trailerVolume, + modifier = Modifier.requiredSize(mediaWidth, mediaHeight) + ) { + if (backdropRequest != null) { + AsyncImage( + model = backdropRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + + if (item != null) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val start = if (isRtl) size.width else 0f + val end = if (isRtl) 0f else size.width + val sideScrim = Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.82f), + 0.35f to Color.Black.copy(alpha = 0.5f), + 0.6f to Color.Transparent, + startX = start, + endX = end + ) + val bottomScrim = Brush.verticalGradient( + 0.55f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.7f), + startY = 0f, + endY = size.height + ) + onDrawBehind { + drawRect(sideScrim) + drawRect(bottomScrim) + } + } + ) + NetflixBillboardInfo( + item = item, + logoUrl = logoUrl, + isFocused = isFocused, + focusedButton = focusedButton, + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(0.58f) + .padding(start = 36.dp, bottom = 30.dp) + ) + } + } + } +} + +private val NetflixTextShadow = Shadow( + color = Color.Black.copy(alpha = 0.85f), + offset = Offset(0f, 2f), + blurRadius = 6f +) + +@Composable +private fun NetflixBillboardInfo( + item: MediaItem, + logoUrl: String?, + isFocused: Boolean, + focusedButton: Int, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = logoUrl, + contentDescription = item.title, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + modifier = Modifier + .height(88.dp) + .widthIn(max = 340.dp) + .fillMaxWidth() + ) + } else { + Text( + text = item.title, + style = ArflixTypography.heroTitle.copy( + fontSize = 36.sp, + fontWeight = FontWeight.Black, + shadow = NetflixTextShadow + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + NetflixMetaLine(item = item, fontSize = 15) + val overview = remember(item.overview, context) { context.cleanOverviewText(item.overview) } + Text( + text = overview, + style = ArflixTypography.body.copy( + fontSize = 15.sp, + lineHeight = 21.sp, + shadow = NetflixTextShadow + ), + color = Color.White, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // With focus elsewhere (the top bar), Play keeps the resting highlight. + NetflixBillboardButton( + label = stringResource(R.string.play), + icon = { tint -> + Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + }, + highlighted = if (isFocused) focusedButton == 0 else true + ) + NetflixBillboardButton( + label = stringResource(R.string.home_more_info), + icon = { tint -> + Icon(Icons.Filled.Info, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp)) + }, + highlighted = isFocused && focusedButton == 1 + ) + } + } +} + +@Composable +private fun NetflixBillboardButton( + label: String, + icon: @Composable (Color) -> Unit, + highlighted: Boolean +) { + val content = if (highlighted) Color.Black else Color.White + Row( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(if (highlighted) Color.White else Color.White.copy(alpha = 0.22f)) + .padding(horizontal = 22.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + icon(content) + Text( + text = label, + style = ArflixTypography.label.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold), + color = content, + maxLines = 1 + ) + } +} + +/** "Series • Drama • 2026 • 16+": the Netflix metadata line. */ +@Composable +private fun NetflixMetaLine(item: MediaItem, fontSize: Int) { + val context = LocalContext.current + val parts = remember(item.id, item.mediaType, item.genreIds, item.year, item.duration, item.contentRating, context) { + buildList { + add(context.getString(if (item.mediaType == MediaType.TV) R.string.series else R.string.movie)) + context.genreNames(item.mediaType, item.genreIds).firstOrNull()?.let(::add) + item.year.takeIf { it.isNotBlank() }?.let(::add) + item.duration.takeIf { it.isNotBlank() && it != "0m" }?.let(::add) + item.contentRating?.takeIf { it.isNotBlank() }?.let(::add) + } + } + // The IMDb rating comes with the title's details, which can land after the card. It is + // never waited for: it joins the end of the line when it arrives, so nothing else moves. + // Kept per title, so it doesn't flicker away when a later copy of the item lacks it. + var imdbRating by remember(item.id, item.mediaType) { mutableStateOf("") } + LaunchedEffect(item.imdbRating) { + if (parseRatingValue(item.imdbRating) > 0f) imdbRating = item.imdbRating + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = parts.joinToString(" • "), + style = ArflixTypography.caption.copy( + fontSize = fontSize.sp, + fontWeight = FontWeight.Bold, + shadow = NetflixTextShadow + ), + color = Color.White.copy(alpha = 0.92f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (imdbRating.isNotEmpty()) { + Text( + text = " • ", + style = ArflixTypography.caption.copy(fontSize = fontSize.sp, fontWeight = FontWeight.Bold), + color = Color.White.copy(alpha = 0.92f) + ) + ImdbSvgRatingBadge( + rating = imdbRating, + imageLoader = context.imageLoader, + ratingFontSize = fontSize, + logoWidth = (fontSize * 2.2f).dp, + logoHeight = (fontSize * 1.1f).dp, + textShadow = NetflixTextShadow + ) + } + } +} + +/** + * A Netflix row. In the focused row the focused title is a wide backdrop card with its + * description below, and the other titles are posters of the same height. Other rows + * keep the regular card size. + */ +@Composable +private fun NetflixContentRow( + category: Category, + cardLogoUrls: Map, + isCurrentRow: Boolean, + focusedItemIndex: Int, + restingItemIndex: Int, + usePosterCards: Boolean, + bigCardHeight: Dp, + startPadding: Dp, + categoryHasMore: Boolean, + showViewAll: Boolean, + featuredItem: MediaItem?, + featuredOverview: String?, + onChannelCardPlaced: (LayoutCoordinates) -> Unit, + trailerKey: String?, + trailerDelayMs: Long, + trailerVolume: Float, + onLoadMore: () -> Unit, + onViewAll: () -> Unit, + onItemClick: (MediaItem) -> Unit +) { + val isContinueWatching = category.id == "continue_watching" + val isRanked = category.title.contains("Top 10", ignoreCase = true) + val rowState = rememberLazyListState() + val items = remember(category.items) { + if (category.items.isEmpty()) { + (1..8).map { MediaItem(id = -it, title = "", mediaType = MediaType.MOVIE, isPlaceholder = true) } + } else { + navigableHomeItems(category.items) + } + } + val itemKeys = remember(category.id, items) { stableHomeRowItemKeys(category.id, items) } + val hasViewAllCard = showViewAll && items.none { it.isPlaceholder } + val bigCardWidth = bigCardHeight * (16f / 9f) + val focusedPosterWidth = bigCardHeight * (2f / 3f) + val restingPoster = category.isPortrait(usePosterCards) + val restingWidth = if (restingPoster) HOME_TV_POSTER_CARD_WIDTH_DP.dp else HOME_TV_LANDSCAPE_CARD_WIDTH_DP.dp + val focusedItem = items.getOrNull(focusedItemIndex)?.takeIf { isCurrentRow && !it.isPlaceholder } + + // The focused title always sits in the first slot; a resting row keeps its position. + val anchorIndex = (if (isCurrentRow) focusedItemIndex else restingItemIndex) + .coerceIn(0, (items.size - 1 + if (hasViewAllCard) 1 else 0).coerceAtLeast(0)) + var lastAnchor by remember { mutableIntStateOf(-1) } + LaunchedEffect(anchorIndex, isCurrentRow) { + if (isCurrentRow && lastAnchor >= 0 && abs(anchorIndex - lastAnchor) <= 3) { + rowState.animateScrollToItem(anchorIndex) + } else { + rowState.scrollToItem(anchorIndex) + } + lastAnchor = anchorIndex + } + val latestOnItemClick by rememberUpdatedState(onItemClick) + + Column(modifier = Modifier.padding(bottom = if (isCurrentRow) 20.dp else 10.dp)) { + Text( + text = localizedCategoryTitle(category), + style = ArflixTypography.sectionTitle.copy( + fontSize = if (isCurrentRow) 20.sp else 17.sp, + fontWeight = FontWeight.Bold, + shadow = NetflixTextShadow + ), + color = Color.White, + modifier = Modifier.padding(start = startPadding, bottom = 8.dp) + ) + LazyRow( + state = rowState, + userScrollEnabled = false, + contentPadding = PaddingValues(start = startPadding, end = bigCardWidth), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + itemsIndexed( + items = items, + key = { index, _ -> itemKeys[index] }, + contentType = { index, item -> + when { + item.isPlaceholder -> "placeholder_card" + isCurrentRow && index == focusedItemIndex -> "netflix_featured_card" + isCurrentRow -> "netflix_poster_card" + else -> "netflix_resting_card" + } + } + ) { index, item -> + if (!item.isPlaceholder && categoryHasMore && index >= items.size - 5) { + LaunchedEffect(items.size) { onLoadMore() } + } + val onClick = remember(item) { { latestOnItemClick(item) } } + // The rank ribbon would cover the card's trailer, so it hides while one plays. + var cardTrailerPlaying by remember(itemKeys[index]) { mutableStateOf(false) } + Box { + when { + isCurrentRow && index == focusedItemIndex && !item.isPlaceholder -> NetflixFeaturedCard( + item = item, + logoUrl = cardLogoUrls["${item.mediaType}_${item.id}"], + width = bigCardWidth, + height = bigCardHeight, + showProgress = isContinueWatching, + trailerKey = trailerKey, + trailerDelayMs = trailerDelayMs, + trailerVolume = trailerVolume, + onTrailerPlaying = { cardTrailerPlaying = it }, + onChannelCardPlaced = onChannelCardPlaced + ) + isCurrentRow -> MediaCard( + item = item, + width = focusedPosterWidth, + isLandscape = false, + showLogoImage = false, + raiseOnFocus = false, + showProgress = isContinueWatching, + showTitle = false, + focusedScale = 1f, + animateFocus = false, + enableSystemFocus = false, + onClick = onClick + ) + else -> MediaCard( + item = item, + width = restingWidth, + isLandscape = !restingPoster, + logoImageUrl = cardLogoUrls["${item.mediaType}_${item.id}"], + showLogoImage = true, + raiseOnFocus = false, + showProgress = isContinueWatching, + showTitle = false, + focusedScale = 1f, + animateFocus = false, + enableSystemFocus = false, + onClick = onClick + ) + } + val trailerCovering = cardTrailerPlaying && isCurrentRow && index == focusedItemIndex + if (isRanked && index < 10 && !item.isPlaceholder && !trailerCovering) { + TopRankRibbon( + rank = index + 1, + isFocused = isCurrentRow && index == focusedItemIndex, + compact = !isCurrentRow && !restingPoster, + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 8.dp) + ) + } + } + } + if (hasViewAllCard) { + item(key = "${category.id}_view_all", contentType = "view_all_card") { + HomeViewAllCard( + width = if (isCurrentRow) focusedPosterWidth else restingWidth, + isLandscape = !isCurrentRow && !restingPoster, + isFocused = isCurrentRow && focusedItemIndex == items.size, + enableSystemFocus = false, + onClick = onViewAll + ) + } + } + } + if (focusedItem != null) { + // The ViewModel hero carries the loaded details once it is this title. + val isFeatured = featuredItem.isSameHomeHero(focusedItem) + val details = featuredItem?.takeIf { isFeatured } ?: focusedItem + NetflixCardDescription( + item = details, + overviewOverride = featuredOverview?.takeIf { isFeatured }, + // Spans the big card and the next poster, as Netflix does, so the + // overview is not cut at the card edge. + modifier = Modifier + .padding(start = startPadding + 4.dp, end = startPadding, top = 10.dp) + .widthIn(max = bigCardWidth + focusedPosterWidth + 12.dp) + .fillMaxWidth() + ) + } + } +} + +@Composable +private fun NetflixCardDescription( + item: MediaItem, + overviewOverride: String?, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val overview = remember(overviewOverride, item.overview, context) { + context.cleanOverviewText(overviewOverride ?: item.overview) + } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (item.isIptvChannel()) NetflixLiveLine(item) else NetflixMetaLine(item = item, fontSize = 13) + Text( + text = overview, + style = ArflixTypography.body.copy(fontSize = 14.sp, lineHeight = 19.sp), + color = Color.White.copy(alpha = 0.9f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun NetflixFeaturedCard( + item: MediaItem, + logoUrl: String?, + width: Dp, + height: Dp, + showProgress: Boolean, + trailerKey: String?, + trailerDelayMs: Long, + trailerVolume: Float, + onTrailerPlaying: (Boolean) -> Unit = {}, + onChannelCardPlaced: (LayoutCoordinates) -> Unit = {} +) { + val context = LocalContext.current + val density = LocalDensity.current + if (item.isIptvChannel()) { + NetflixChannelCard(item, width, height, onChannelCardPlaced) + return + } + val focusedAtMs = remember(item.id, item.mediaType) { SystemClock.elapsedRealtime() } + val imageRequest = remember(item.backdrop, item.image, width, height) { + (item.backdrop ?: item.image).takeIf { it.isNotBlank() }?.let { url -> + val widthPx = with(density) { width.roundToPx() }.coerceAtLeast(1) + val heightPx = with(density) { height.roundToPx() }.coerceAtLeast(1) + ImageRequest.Builder(context) + .data(TmdbImageSizing.forSlot(url, widthPx, heightPx, TmdbImageSizing.Kind.BACKDROP)) + .size(widthPx, heightPx) + .precision(Precision.INEXACT) + .build() + } + } + Box( + modifier = Modifier + .width(width) + .height(height) + .border(2.5.dp, Color.White, RoundedCornerShape(10.dp)) + // Keeps the focus outline off the trailer. + .padding(4.dp) + .clip(RoundedCornerShape(7.dp)) + .background(Color.Black) + .testTag("netflix_card_${item.mediaType}_${item.id}") + ) { + HomeTrailerPreview( + youtubeKey = trailerKey, + delayMs = trailerDelayMs, + autoplayNotBeforeMs = focusedAtMs + trailerDelayMs.coerceAtLeast(500L), + volume = trailerVolume, + onStateChange = { state -> onTrailerPlaying(state.coversTrailer()) }, + modifier = Modifier.fillMaxSize() + ) { + Box(Modifier.fillMaxSize()) { + if (imageRequest != null) { + AsyncImage( + model = imageRequest, + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0.5f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.65f) + ) + ) + ) + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = logoUrl, + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, bottom = if (showProgress) 22.dp else 16.dp) + .fillMaxWidth(0.45f) + .height(64.dp) + ) + } else { + Text( + text = item.title, + style = ArflixTypography.sectionTitle.copy( + fontSize = 22.sp, + fontWeight = FontWeight.Black, + shadow = NetflixTextShadow + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 16.dp, bottom = 16.dp) + .fillMaxWidth(0.6f) + ) + } + if (showProgress && item.progress > 0) { + Box( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f)) + ) { + Box( + Modifier + .fillMaxWidth(item.progress.coerceIn(0, 100) / 100f) + .fillMaxHeight() + .background(Color(0xFFE50914)) + ) + } + } + } + } + } +} + +private fun MediaItem.isIptvChannel(): Boolean = status?.startsWith("iptv:") == true + +/** "LIVE Channel • Group": the channel's metadata line. */ +@Composable +private fun NetflixLiveLine(item: MediaItem) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.live).uppercase(), + style = ArflixTypography.caption.copy(fontSize = 11.sp, fontWeight = FontWeight.Black), + color = Color.White, + modifier = Modifier + .background(Color(0xFFE50914), RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) + Text( + text = listOf(item.title, item.subtitle).filter { it.isNotBlank() }.distinct().joinToString(" • "), + style = ArflixTypography.caption.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold), + color = Color.White.copy(alpha = 0.92f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +/** The focused channel's card: border and logo. Its live preview is [NetflixChannelPreview]. */ +@Composable +private fun NetflixChannelCard( + item: MediaItem, + width: Dp, + height: Dp, + onMediaPlaced: (LayoutCoordinates) -> Unit +) { + Box( + modifier = Modifier + .width(width) + .height(height) + .border(2.5.dp, Color.White, RoundedCornerShape(10.dp)) + .padding(4.dp) + .onGloballyPositioned(onMediaPlaced) + .clip(RoundedCornerShape(7.dp)) + .testTag("netflix_channel_${item.id}") + ) { + NetflixChannelLogo(item) + } +} + +@Composable +private fun NetflixChannelLogo(item: MediaItem) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF15161A)), + contentAlignment = Alignment.Center + ) { + if (item.image.isNotBlank()) { + AsyncImage( + model = item.image, + contentDescription = item.title, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth(0.4f) + .fillMaxHeight(0.5f) + ) + } else { + Text( + text = item.title, + style = ArflixTypography.sectionTitle.copy(fontSize = 24.sp, fontWeight = FontWeight.Black), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** + * The live preview over the focused channel card. The logo covers the surface until the + * focused channel's own stream renders, so the previous channel's last frame never shows + * on the new card. + */ +@Composable +private fun NetflixChannelPreview( + player: androidx.media3.exoplayer.ExoPlayer, + focusedItem: MediaItem, + streamItem: MediaItem?, + modifier: Modifier = Modifier +) { + var frameShown by remember(focusedItem.id) { mutableStateOf(false) } + val latestFocused by rememberUpdatedState(focusedItem) + val latestStream by rememberUpdatedState(streamItem) + DisposableEffect(player, focusedItem.id) { + val listener = object : androidx.media3.common.Player.Listener { + override fun onRenderedFirstFrame() { + if (latestStream.isSameHomeHero(latestFocused)) frameShown = true + } + + // Returning to the channel that is still prepared resumes without a new first frame. + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying && player.videoSize.width > 0 && latestStream.isSameHomeHero(latestFocused)) { + frameShown = true + } + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + Box(modifier = modifier.clip(RoundedCornerShape(7.dp))) { + HomeHeroVideoSurface(player = player, modifier = Modifier.fillMaxSize()) + if (!frameShown) NetflixChannelLogo(focusedItem) + } +} + +/** Whether the official player is on screen (started and not finished). */ +private fun PlayerConstants.PlayerState.coversTrailer(): Boolean = when (this) { + PlayerConstants.PlayerState.PLAYING, + PlayerConstants.PlayerState.BUFFERING, + PlayerConstants.PlayerState.PAUSED -> true + else -> false +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 3dbdcffd4..ca40a02d7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -3011,6 +3011,9 @@ fun PlayerScreen( ) } + // Checked before any progress is saved: a live stream's window looks like a duration. + if (exoPlayer.isCurrentMediaItemLive) viewModel.onLivePlaybackDetected() + if (currentPosition > 0 && duration > 0) { val currentSecond = (currentPosition / 1000L).coerceAtLeast(0L) val shouldReport = @@ -3045,6 +3048,7 @@ fun PlayerScreen( playerReleased = true if (!nextEpisodeTransitionInProgress) { runCatching { + if (exoPlayer.isCurrentMediaItemLive) viewModel.onLivePlaybackDetected() val safeDuration = exoPlayer.duration.takeIf { it > 0L && it != C.TIME_UNSET } ?: 0L val safeProgressPercent = if (safeDuration > 0L) { ((exoPlayer.currentPosition.toDouble() / safeDuration.toDouble()) * 100.0) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index d393c43fa..fc35b4059 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -8020,6 +8020,24 @@ class PlayerViewModel @Inject constructor( return job } + /** + * The player found the stream is live. Addons don't always label their channels as live + * (some list them as movies), so the stream decides: from here on it gets no progress, + * scrobbles or Continue Watching entry, and one saved before this fix is removed. + */ + fun onLivePlaybackDetected() { + if (currentIsLiveStreamPlayback) return + currentIsLiveStreamPlayback = true + val mediaType = currentMediaType + val mediaId = currentMediaId + viewModelScope.launch { + runCatching { watchHistoryRepository.removeFromHistory(mediaId, null, null) } + runCatching { traktRepository.removeFromContinueWatchingCache(mediaId, null, null, mediaType) } + runCatching { cloudSyncRepository.pushToCloud() } + runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } + } + } + suspend fun saveProgressAndWait( position: Long, duration: Long, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index bc68348c6..2640e25e7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -328,7 +328,7 @@ private fun tvGeneralRowsForSection(section: String): List { listOf(39, 9, 45) "ai_subtitles" -> listOf(28, 29, 30, 31, 32, 33) "playback" -> listOf(10, 11, 12, 43, 44, 13, 14, 34, 37, 16, 15, 40, 27) - "appearance" -> listOf(17, 18, 20, 21, 24, 23, 22, 41, 46, 36, 47) + "appearance" -> listOf(17, 49, 18, 20, 21, 24, 23, 22, 41, 46, 36, 47) "profiles" -> listOf(19) "network" -> listOf(25, 26, 35) else -> emptyList() @@ -1466,6 +1466,7 @@ fun SettingsScreen( 34 -> viewModel.cycleTrailerDelay() 47 -> viewModel.cycleGuideRowCount() 37 -> viewModel.setTrailerInCards(!uiState.trailerInCards) + 49 -> viewModel.setNetflixHomeStyle(!uiState.netflixHomeStyle) } } "iptv" -> { @@ -2085,6 +2086,8 @@ fun SettingsScreen( onTrailerSoundEnabledToggle = { viewModel.setTrailerSoundEnabled(it) }, trailerInCards = uiState.trailerInCards, onTrailerInCardsToggle = { viewModel.setTrailerInCards(it) }, + netflixHomeStyle = uiState.netflixHomeStyle, + onNetflixHomeStyleToggle = { viewModel.setNetflixHomeStyle(it) }, trailerDelaySeconds = uiState.trailerDelaySeconds, onTrailerDelayClick = { viewModel.cycleTrailerDelay() }, guideRowCount = uiState.guideRowCount, @@ -7199,6 +7202,8 @@ private fun TvGeneralSettingsRows( onTrailerSoundEnabledToggle: (Boolean) -> Unit = {}, trailerInCards: Boolean = true, onTrailerInCardsToggle: (Boolean) -> Unit = {}, + netflixHomeStyle: Boolean = true, + onNetflixHomeStyleToggle: (Boolean) -> Unit = {}, trailerDelaySeconds: Int = 1, onTrailerDelayClick: () -> Unit = {}, guideRowCount: Int = 0, @@ -7359,6 +7364,7 @@ private fun TvGeneralSettingsRows( 47 -> SettingsRow(Icons.Default.TableRows, stringResource(R.string.guide_rows), stringResource(R.string.guide_rows_desc), if (guideRowCount == 0) stringResource(R.string.auto) else "$guideRowCount", focusedIndex == localIndex, onGuideRowCountClick, Modifier.settingsFocusSlot(localIndex)) 35 -> SettingsRow(Icons.Default.Language, stringResource(R.string.custom_user_agent), stringResource(R.string.custom_user_agent_desc), formatUserAgentPreview(customUserAgent, 30), focusedIndex == localIndex, onCustomUserAgentClick, Modifier.settingsFocusSlot(localIndex)) 37 -> SettingsToggleRow(stringResource(R.string.trailer_in_cards), stringResource(R.string.trailer_in_cards_desc), trailerInCards, focusedIndex == localIndex, onTrailerInCardsToggle, Modifier.settingsFocusSlot(localIndex)) + 49 -> SettingsToggleRow(stringResource(R.string.netflix_home_style), stringResource(R.string.netflix_home_style_desc), netflixHomeStyle, focusedIndex == localIndex, onNetflixHomeStyleToggle, Modifier.settingsFocusSlot(localIndex)) } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 61da7d473..3958d9f2c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -235,6 +235,7 @@ data class SettingsUiState( /** 0 keeps the fixed row height; 6..10 divides the TV guide into that many rows. */ val guideRowCount: Int = 0, val trailerInCards: Boolean = true, + val netflixHomeStyle: Boolean = true, val showBudget: Boolean = true, val showEpisodeRatings: Boolean = false, val animeStructuringStyle: AnimeStructuringStyle = AnimeStructuringStyle.BROADCAST, @@ -483,6 +484,7 @@ class SettingsViewModel @Inject constructor( private fun trailerDelayKey() = profileManager.profileStringKey("trailer_delay_seconds") private fun guideRowCountKey() = profileManager.profileStringKey("guide_row_count") private fun trailerInCardsKey() = profileManager.profileBooleanKey("trailer_in_cards") + private fun netflixHomeStyleKey() = profileManager.profileBooleanKey(com.arflix.tv.ui.screens.home.NETFLIX_HOME_STYLE_KEY) private fun showBudgetKey() = profileManager.profileBooleanKey("show_budget_on_home") private fun showEpisodeRatingsKey() = profileManager.profileBooleanKey("show_episode_ratings") private fun animeStructuringStyleKey() = profileManager.profileStringKey(AnimeStructuringStyle.PREFERENCE_KEY) @@ -719,6 +721,7 @@ class SettingsViewModel @Inject constructor( val guideRowCount = prefs[guideRowCountKey()]?.toIntOrNull() ?.takeIf { it in 6..10 } ?: 0 val trailerInCards = prefs[trailerInCardsKey()] ?: true + val netflixHomeStyle = prefs[netflixHomeStyleKey()] ?: true val spoilerBlurEnabled = prefs[spoilerBlurKey()] ?: false val showBudget = prefs[showBudgetKey()] ?: true val showEpisodeRatings = prefs[showEpisodeRatingsKey()] ?: false @@ -838,6 +841,7 @@ class SettingsViewModel @Inject constructor( trailerDelaySeconds = trailerDelaySeconds, guideRowCount = guideRowCount, trailerInCards = trailerInCards, + netflixHomeStyle = netflixHomeStyle, showBudget = showBudget, showEpisodeRatings = showEpisodeRatings, animeStructuringStyle = animeStructuringStyle, @@ -1886,6 +1890,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { context.settingsDataStore.edit { it[trailerInCardsKey()] = enabled }; _uiState.value = _uiState.value.copy(trailerInCards = enabled); syncLocalStateToCloud(silent = true) } } + fun setNetflixHomeStyle(enabled: Boolean) { + viewModelScope.launch { context.settingsDataStore.edit { it[netflixHomeStyleKey()] = enabled }; _uiState.value = _uiState.value.copy(netflixHomeStyle = enabled); syncLocalStateToCloud(silent = true) } + } + fun cycleGuideRowCount() { val next = when (val n = _uiState.value.guideRowCount) { in 6..9 -> n + 1 diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index e1fb588d7..1664798c0 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -119,6 +119,9 @@ שניות לפני שהטריילר מתחיל לנגן טריילרים בכרטיסים טלוויזיה: הצגת קדימונים בכרטיס המוגדל במקום באזור הראשי + דף בית בסגנון Netflix + טלוויזיה: באנר ענק עם טריילר, הכרטיס הממוקד מתרחב עם תיאור + מידע נוסף כבוי, חלק, או תמיד הוצא רמות איכות במכשיר זה כרטיסים לרוחב או לגובה diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d5e9ba24..3b612ef9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,6 +203,9 @@ Channel rows shown in the TV guide Trailers In Cards TV: preview trailers in the expanded card instead of the hero + Netflix-Style Home + TV: large featured billboard with trailer, focused card expands with its description + More Info Off, Seamless, or Always Exclude quality tiers on this device Landscape or poster cards diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/NetflixHomeStylePreferenceTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/NetflixHomeStylePreferenceTest.kt new file mode 100644 index 000000000..fb7cf185d --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/NetflixHomeStylePreferenceTest.kt @@ -0,0 +1,33 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.repository.CloudSyncRepository.CloudProfileSettings +import com.arflix.tv.ui.screens.settings.SettingsUiState +import com.google.common.truth.Truth.assertThat +import com.google.gson.Gson +import org.junit.Test + +class NetflixHomeStylePreferenceTest { + private val gson = Gson() + + @Test + fun netflixHomeIsTheDefault() { + assertThat(SettingsUiState().netflixHomeStyle).isTrue() + } + + @Test + fun missingCloudFieldDoesNotRequestALocalOverwrite() { + assertThat(CloudProfileSettings().netflixHomeStyle).isNull() + assertThat(gson.fromJson("{}", CloudProfileSettings::class.java).netflixHomeStyle).isNull() + } + + @Test + fun bothChoicesSurviveCloudRoundTrip() { + for (enabled in listOf(true, false)) { + val restored = gson.fromJson( + gson.toJson(CloudProfileSettings(netflixHomeStyle = enabled)), + CloudProfileSettings::class.java, + ) + assertThat(restored.netflixHomeStyle).isEqualTo(enabled) + } + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferencesTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferencesTest.kt index 703d24d16..76514cc74 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferencesTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/home/HomeProfilePreferencesTest.kt @@ -91,6 +91,7 @@ class HomeProfilePreferencesTest { assertThat(settings.trailerSoundEnabled).isFalse() assertThat(settings.trailerDelaySeconds).isEqualTo(1) assertThat(settings.trailerInCards).isTrue() + assertThat(settings.netflixHomeStyle).isTrue() assertThat(settings.showBudget).isTrue() assertThat(settings.iptvFavoritesOnHome).isTrue() assertThat(settings.clockFormat).isEqualTo("24h") diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/home/NetflixHomeTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/home/NetflixHomeTest.kt new file mode 100644 index 000000000..8c9ac2f20 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/home/NetflixHomeTest.kt @@ -0,0 +1,63 @@ +package com.arflix.tv.ui.screens.home + +import androidx.compose.runtime.saveable.SaverScope +import com.arflix.tv.data.model.Category +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import org.junit.Assert.* +import org.junit.Test + +class NetflixHomeTest { + @Test fun billboardPrefersTodaysTopTenRows() { + val rows = listOf( + row("continue_watching", 1), + row("trending_movies", 2), + row("top10_movies_today", 3), + row("top10_shows_today", 4, mediaType = MediaType.TV), + ) + assertEquals(listOf(3, 4), candidateIds(rows)) + } + + @Test fun billboardFallsBackToOtherTopTenThenTrending() { + val otherTopTen = listOf(row("addon_catalog", 5, title = "Top 10 in Israel"), row("trending_tv", 6)) + assertEquals(listOf(5), candidateIds(otherTopTen)) + assertEquals(listOf(6), candidateIds(listOf(row("popular", 7), row("trending_tv", 6)))) + assertTrue(candidateIds(listOf(row("popular", 7))).isEmpty()) + } + + @Test fun billboardSkipsTitlesItCannotShow() { + val rows = listOf( + Category( + "top10_movies_today", "Top 10", + listOf( + item(1, backdrop = null), + item(2).copy(isPlaceholder = true), + item(3).copy(status = "iptv:channel"), + item(4), + item(4), + ) + ) + ) + val ids = netflixBillboardCandidates(rows) { it.status?.startsWith("iptv:") == true }.map { it.id } + assertEquals(listOf(4), ids) + } + + @Test fun billboardFocusSurvivesSavedState() { + val state = HomeFocusState().apply { isHeroFocused = true } + val saved = with(HomeFocusState.Saver) { SaverScope { true }.save(state)!! } + assertTrue(HomeFocusState.Saver.restore(saved)!!.isHeroFocused) + } + + @Test fun stateSavedBeforeTheBillboardRestoresUnfocused() { + val legacy = listOf(0, 0, 1, 0, "", HashMap(), HashMap(), false) + assertFalse(HomeFocusState.Saver.restore(legacy)!!.isHeroFocused) + } + + private fun candidateIds(rows: List) = netflixBillboardCandidates(rows) { false }.map { it.id } + + private fun row(id: String, vararg ids: Int, title: String = id, mediaType: MediaType = MediaType.MOVIE) = + Category(id, title, ids.map { item(it, mediaType = mediaType) }) + + private fun item(id: Int, backdrop: String? = "https://image.example/$id.jpg", mediaType: MediaType = MediaType.MOVIE) = + MediaItem(id, "Title $id", mediaType = mediaType, backdrop = backdrop) +}