From 57afc03337260d6a2a2ab7aa2d91d42c7b4ed098 Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 16 Oct 2026 09:34:29 +0300 Subject: [PATCH 1/3] feat(home): Netflix-style TV Home Add a Netflix-style layout for the TV Home screen, on by default and switchable per profile in Settings > Appearance (synced to the cloud). - Billboard: a large featured title above the rows, picked at random from today's Top 10 movies/series (falling back to other Top 10 rows, then Trending). It shows the logo, metadata, overview and Play / More Info, and plays the title's trailer while focused. - Rows: the focused card of the focused row expands to a wide card with its trailer; the other cards in that row become posters of the same height, and the title's metadata and overview show below the card. - Favorite TV: the focused channel expands the same way and plays its live preview inside the card, on one shared video surface so moving between channels keeps video and audio in step. - Service and sports rows keep the classic rail. --- .../tv/data/repository/CloudSyncRepository.kt | 6 + .../ui/screens/home/HomeProfilePreferences.kt | 5 + .../arflix/tv/ui/screens/home/HomeScreen.kt | 232 +++- .../tv/ui/screens/home/HomeViewModel.kt | 2 + .../arflix/tv/ui/screens/home/NetflixHome.kt | 1097 +++++++++++++++++ .../tv/ui/screens/settings/SettingsScreen.kt | 8 +- .../ui/screens/settings/SettingsViewModel.kt | 8 + app/src/main/res/values-iw/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../NetflixHomeStylePreferenceTest.kt | 33 + .../home/HomeProfilePreferencesTest.kt | 1 + .../tv/ui/screens/home/NetflixHomeTest.kt | 63 + 12 files changed, 1424 insertions(+), 37 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/screens/home/NetflixHome.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/NetflixHomeStylePreferenceTest.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/screens/home/NetflixHomeTest.kt 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..55f7388de --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/NetflixHome.kt @@ -0,0 +1,1097 @@ +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 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) + } + } + 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 + ) +} + +/** + * 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/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) +} From 57f978c7d25fae0434305e7b43af64eab8256d69 Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 16 Oct 2026 13:00:03 +0300 Subject: [PATCH 2/3] fix(player): keep live streams out of Continue Watching Some addons list their live channels as movies, so a channel played from them was saved to Continue Watching (its live window looks like a duration) and came back every time it was watched. The player now decides: once it reports the stream as live, the playback gets no progress, scrobbles or Continue Watching entry, whatever the addon calls the item, and an entry saved for it earlier is removed. --- .../tv/ui/screens/player/PlayerScreen.kt | 4 ++++ .../tv/ui/screens/player/PlayerViewModel.kt | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) 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, From 943dd1ea12462ba4c0a185174da4f7e7275a380a Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 16 Oct 2026 13:49:25 +0300 Subject: [PATCH 3/3] add rating --- .../arflix/tv/ui/screens/home/NetflixHome.kt | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) 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 index 55f7388de..7f33de8ac 100644 --- 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 @@ -84,6 +84,9 @@ 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 @@ -642,17 +645,42 @@ private fun NetflixMetaLine(item: MediaItem, fontSize: Int) { item.contentRating?.takeIf { it.isNotBlank() }?.let(::add) } } - 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 - ) + // 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 + ) + } + } } /**