From ccf58dc06dd9fa54589a06ed38d2cb5c189b0d03 Mon Sep 17 00:00:00 2001 From: Bruno Procopio Date: Mon, 7 Sep 2026 02:22:55 -0300 Subject: [PATCH] feat(home): improve trailer playback and hero backdrop integration (#663) --- .../main/kotlin/com/arflix/tv/MainActivity.kt | 7 +- .../tv/core/player/LocalTrailerPlayerPool.kt | 9 + .../tv/core/player/TrailerPlayerPool.kt | 202 ++++++++++++ .../com/arflix/tv/ui/components/MediaCard.kt | 56 +++- .../arflix/tv/ui/components/TrailerPlayer.kt | 267 ++++++++++++---- .../arflix/tv/ui/screens/home/HomeScreen.kt | 292 +++++++++++------- .../tv/ui/screens/home/HomeViewModel.kt | 91 +++--- .../tv/ui/screens/player/PlayerScreen.kt | 8 + 8 files changed, 696 insertions(+), 236 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/core/player/LocalTrailerPlayerPool.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/core/player/TrailerPlayerPool.kt diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 65728535d..7a54546dc 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -169,6 +169,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var iptvRepository: Lazy + @Inject + lateinit var trailerPlayerPool: com.arflix.tv.core.player.TrailerPlayerPool + private var jankStats: JankStats? = null private var pendingLauncherRequest by mutableStateOf(null) private var pendingInstallPackUrl by mutableStateOf(null) @@ -342,7 +345,8 @@ class MainActivity : ComponentActivity() { LocalHasTouchScreen provides hasTouchScreen, androidx.compose.ui.platform.LocalLayoutDirection provides if (isRtl) androidx.compose.ui.unit.LayoutDirection.Rtl - else androidx.compose.ui.unit.LayoutDirection.Ltr + else androidx.compose.ui.unit.LayoutDirection.Ltr, + com.arflix.tv.core.player.LocalTrailerPlayerPool provides trailerPlayerPool ) { ArflixTvTheme( oledBlackBackground = oledBlackBackground, @@ -429,6 +433,7 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { jankStats?.isTrackingEnabled = false jankStats = null + runCatching { trailerPlayerPool.release() } super.onDestroy() } } diff --git a/app/src/main/kotlin/com/arflix/tv/core/player/LocalTrailerPlayerPool.kt b/app/src/main/kotlin/com/arflix/tv/core/player/LocalTrailerPlayerPool.kt new file mode 100644 index 000000000..891d74dd9 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/core/player/LocalTrailerPlayerPool.kt @@ -0,0 +1,9 @@ +package com.arflix.tv.core.player + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * CompositionLocal providing access to the shared [TrailerPlayerPool] singleton. + * Provided at the root Activity level. + */ +val LocalTrailerPlayerPool = staticCompositionLocalOf { null } diff --git a/app/src/main/kotlin/com/arflix/tv/core/player/TrailerPlayerPool.kt b/app/src/main/kotlin/com/arflix/tv/core/player/TrailerPlayerPool.kt new file mode 100644 index 000000000..6217f232d --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/core/player/TrailerPlayerPool.kt @@ -0,0 +1,202 @@ +package com.arflix.tv.core.player + +import android.content.Context +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import android.os.Handler +import android.os.Looper +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Application-scoped singleton that holds a single ExoPlayer instance dedicated to + * trailer preview playback on the home and details screens. + * + * Creating and tearing down ExoPlayer for every poster focus is extremely expensive + * on Android TV hardware (codec initialization, hardware decoder allocation). This pool keeps + * one instance alive and reuses it across focus changes. The player is stopped and cleared + * between uses but never released until the process terminates or [release] is explicitly called. + * + * Strict focus synchronization is maintained via owner tokens so that focus changes immediately + * invalidate and cancel any previous or pending playback. + * + * When the full-screen player needs hardware decoders, call [yield] to free + * codec resources without destroying the instance. Call [reclaim] when returning to + * the home screen to lazily rebuild if needed. + */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@Singleton +class TrailerPlayerPool @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private const val TAG = "TrailerPlayerPool" + } + + private var _player: ExoPlayer? = null + private val yielded = AtomicBoolean(false) + private val released = AtomicBoolean(false) + + @Volatile + private var activeOwnerToken: String? = null + private val lock = Any() + private val mainHandler = Handler(Looper.getMainLooper()) + + private fun runOnMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } + + /** + * Returns the shared trailer ExoPlayer, creating it lazily if needed. + * If [ownerToken] is specified, any playback from a different owner is stopped immediately. + */ + fun acquire(ownerToken: String? = null): ExoPlayer? { + synchronized(lock) { + if (released.get()) return null + if (yielded.get()) { + // Reclaim was not called yet but someone wants the player — rebuild lazily. + reclaim() + } + if (ownerToken != null && activeOwnerToken != null && activeOwnerToken != ownerToken) { + stopInternal() + } + if (ownerToken != null) { + activeOwnerToken = ownerToken + } + return _player ?: createPlayer().also { _player = it } + } + } + + /** + * Checks whether [ownerToken] is still the active owner of the player. + */ + fun isCurrentOwner(ownerToken: String?): Boolean { + if (ownerToken == null) return false + return activeOwnerToken == ownerToken && !released.get() && !yielded.get() + } + + /** + * Stops playback and clears media items immediately. + * If [ownerToken] is specified, only stops if [ownerToken] matches the current active owner. + */ + fun stop(ownerToken: String? = null) { + synchronized(lock) { + if (ownerToken != null && activeOwnerToken != null && activeOwnerToken != ownerToken) { + return + } + if (ownerToken == null || activeOwnerToken == ownerToken) { + activeOwnerToken = null + } + stopInternal() + } + } + + private fun stopInternal() { + runOnMain { + _player?.let { player -> + runCatching { + player.playWhenReady = false + player.stop() + player.clearMediaItems() + } + } + } + } + + /** + * Releases codec resources so the main video player can claim hardware decoders. + * The ExoPlayer instance is released here; [reclaim] will allow creating a fresh one. + */ + fun yield() { + if (yielded.compareAndSet(false, true)) { + Log.d(TAG, "Yielding trailer player for main video playback") + synchronized(lock) { + activeOwnerToken = null + runOnMain { + _player?.let { player -> + runCatching { player.stop() } + runCatching { player.clearMediaItems() } + runCatching { player.release() } + } + _player = null + } + } + } + } + + /** + * Re-enables player creation after a [yield]. Safe to call multiple times. + */ + fun reclaim() { + if (released.get()) return + if (yielded.compareAndSet(true, false)) { + Log.d(TAG, "Reclaiming trailer player") + // Player will be lazily created on next acquire() + } + } + + /** + * Permanently releases the player. Called on process termination / onDestroy. + */ + fun release() { + if (released.compareAndSet(false, true)) { + synchronized(lock) { + activeOwnerToken = null + runOnMain { + _player?.let { player -> + runCatching { player.stop() } + runCatching { player.clearMediaItems() } + runCatching { player.release() } + } + _player = null + } + } + } + } + + private fun createPlayer(): ExoPlayer { + Log.d(TAG, "Creating shared trailer ExoPlayer instance") + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + /* minBufferMs = */ 15_000, + /* maxBufferMs = */ 60_000, + /* bufferForPlaybackMs = */ 2_500, + /* bufferForPlaybackAfterRebufferMs = */ 5_000 + ) + .build() + + val trackSelector = DefaultTrackSelector(context).apply { + setParameters( + buildUponParameters() + .setMaxVideoSizeSd() + .clearVideoSizeConstraints() + .setForceHighestSupportedBitrate(true) + ) + } + + return ExoPlayer.Builder(context) + .setLoadControl(loadControl) + .setTrackSelector(trackSelector) + .setBandwidthMeter( + DefaultBandwidthMeter.Builder(context) + .setInitialBitrateEstimate(25_000_000L) + .build() + ) + .setVideoChangeFrameRateStrategy(C.VIDEO_CHANGE_FRAME_RATE_STRATEGY_ONLY_IF_SEAMLESS) + .build() + .apply { + repeatMode = Player.REPEAT_MODE_OFF + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt index 4de2a67c5..ed3ba7d90 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -23,6 +25,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import com.arflix.tv.core.player.TrailerPlayerPool import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale @@ -732,12 +736,22 @@ fun FeaturedMediaCard( width: Dp, height: Dp, trailerKey: String?, - trailerDelayMs: Long, - trailerVolume: Float, + trailerDelayMs: Long = 0L, + trailerVolume: Float = 0f, + ownerToken: String? = null, + trailerPlayerPool: TrailerPlayerPool? = null, onClick: () -> Unit, ) { val shape = rememberArvioCardShape(ArvioSkin.radius.md) val imageUrl = (item.backdrop ?: item.image).takeIf { it.isNotBlank() } + val effectiveToken = ownerToken ?: "${item.mediaType}_${item.id}" + var trailerFirstFrameRendered by remember(trailerKey) { mutableStateOf(false) } + + val trailerCoverAlpha by animateFloatAsState( + targetValue = if (!trailerFirstFrameRendered) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "trailerCoverAlpha" + ) ArvioFocusableSurface( modifier = Modifier.size(width, height), @@ -752,22 +766,44 @@ fun FeaturedMediaCard( isFocusedOverride = true, onClick = onClick, ) { _ -> - if (imageUrl != null) { - AsyncImage( - model = imageUrl, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } + // Black backdrop base + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) + + // In-card trailer playback strictly bounded inside the card if (trailerKey != null) { TrailerPlayer( youtubeKey = trailerKey, delayMs = trailerDelayMs, volume = trailerVolume, + cropToFill = true, + overscanZoom = 1.35f, + ownerToken = effectiveToken, + trailerPlayerPool = trailerPlayerPool, + onFirstFrameRendered = { + trailerFirstFrameRendered = true + }, modifier = Modifier.fillMaxSize() ) } + + // Static artwork cover that smoothly fades out after the first video frame is rendered (Nuvio pattern) + if (imageUrl != null && (trailerKey == null || trailerCoverAlpha > 0.01f)) { + AsyncImage( + model = imageUrl, + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = if (trailerKey != null) trailerCoverAlpha else 1f + } + ) + } + // Bottom gradient so title text is readable over the backdrop/trailer Box( modifier = Modifier diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/TrailerPlayer.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/TrailerPlayer.kt index b2f6218e0..9b9f8c1ed 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/TrailerPlayer.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/TrailerPlayer.kt @@ -1,10 +1,8 @@ package com.arflix.tv.ui.components import android.view.LayoutInflater -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -16,11 +14,13 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer @@ -29,6 +29,8 @@ import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import com.arflix.tv.R +import com.arflix.tv.core.player.LocalTrailerPlayerPool +import com.arflix.tv.core.player.TrailerPlayerPool import com.arflix.tv.data.api.InAppYouTubeExtractor import com.arflix.tv.data.api.YoutubeChunkedDataSourceFactory import dagger.hilt.EntryPoint @@ -38,12 +40,14 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import kotlinx.coroutines.isActive /** - * Muted YouTube trailer player using ExoPlayer with direct YouTube stream extraction. + * YouTube / direct stream trailer player reusing the shared [TrailerPlayerPool] singleton. * Waits [delayMs] before resolving and playing (shows static backdrop first). - * Uses TextureView (via layout XML) so AnimatedVisibility's fadeIn works without a black flash — - * the view is transparent during the fade while ExoPlayer buffers in the background. + * + * Uses TextureView (via layout XML) and synchronizes visibility to [Player.Listener.onRenderedFirstFrame] + * so playback crossfades seamlessly without black frames while ExoPlayer buffers in background. */ @EntryPoint @InstallIn(SingletonComponent::class) @@ -54,124 +58,257 @@ interface TrailerPlayerEntryPoint { @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @Composable fun TrailerPlayer( - youtubeKey: String, + youtubeKey: String? = null, + trailerUrl: String? = null, + trailerAudioUrl: String? = null, modifier: Modifier = Modifier, delayMs: Long = 0L, volume: Float = 0f, - onPlayingChanged: (Boolean) -> Unit = {} + cropToFill: Boolean = true, + overscanZoom: Float = 1.35f, + ownerToken: String? = null, + onPlayingChanged: (Boolean) -> Unit = {}, + onFirstFrameRendered: () -> Unit = {}, + trailerPlayerPool: TrailerPlayerPool? = null ) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val currentOnPlayingChanged by rememberUpdatedState(onPlayingChanged) + val currentOnFirstFrameRendered by rememberUpdatedState(onFirstFrameRendered) var shouldPlay by remember { mutableStateOf(false) } - var videoUrl by remember { mutableStateOf(null) } - var audioUrl by remember { mutableStateOf(null) } + var resolvedVideoUrl by remember { mutableStateOf(trailerUrl) } + var resolvedAudioUrl by remember { mutableStateOf(trailerAudioUrl) } + var hasRenderedFirstFrame by remember(youtubeKey, trailerUrl) { mutableStateOf(false) } + + val resolvedToken = ownerToken ?: youtubeKey ?: trailerUrl ?: "trailer_default" + val resolvedPool = trailerPlayerPool ?: LocalTrailerPlayerPool.current val entryPoint = remember { EntryPointAccessors.fromApplication(context, TrailerPlayerEntryPoint::class.java) } val extractor = remember { entryPoint.inAppYouTubeExtractor() } - LaunchedEffect(youtubeKey) { + val resolvedKey = youtubeKey?.takeIf { it.isNotBlank() } + + // Resolve playback URLs + LaunchedEffect(resolvedKey, trailerUrl, trailerAudioUrl, delayMs, resolvedToken) { shouldPlay = false - videoUrl = null - audioUrl = null - delay(delayMs) + hasRenderedFirstFrame = false + + if (delayMs > 0L) { + delay(delayMs) + } + + if (resolvedPool != null && !resolvedPool.isCurrentOwner(resolvedToken)) { + return@LaunchedEffect + } + + if (!trailerUrl.isNullOrBlank()) { + resolvedVideoUrl = trailerUrl + resolvedAudioUrl = trailerAudioUrl + shouldPlay = true + return@LaunchedEffect + } + + if (resolvedKey == null) { + resolvedVideoUrl = null + resolvedAudioUrl = null + currentOnPlayingChanged(false) + return@LaunchedEffect + } + + var videoUrl: String? = null + var audioUrl: String? = null withContext(Dispatchers.IO) { try { - val source = extractor.extractPlaybackSource("https://www.youtube.com/watch?v=$youtubeKey") + val source = extractor.extractPlaybackSource("https://www.youtube.com/watch?v=$resolvedKey") if (source != null) { videoUrl = source.videoUrl audioUrl = source.audioUrl } } catch (_: Exception) {} } - if (videoUrl != null) { + + // Abort if coroutine was cancelled or focus changed to another card during network extraction + if (!isActive || (resolvedPool != null && !resolvedPool.isCurrentOwner(resolvedToken))) { + return@LaunchedEffect + } + + resolvedVideoUrl = videoUrl + resolvedAudioUrl = audioUrl + + if (!resolvedVideoUrl.isNullOrBlank()) { shouldPlay = true - currentOnPlayingChanged(true) } else { currentOnPlayingChanged(false) } } - // TextureView (set via XML) means the view is transparent during the fade-in, - // so the backdrop image shows through while ExoPlayer buffers. No black flash. - AnimatedVisibility( - visible = shouldPlay && videoUrl != null, - enter = fadeIn(animationSpec = tween(800)), - exit = fadeOut(), - modifier = modifier - ) { - val player = remember(youtubeKey) { - ExoPlayer.Builder(context).build().apply { - repeatMode = Player.REPEAT_MODE_OFF - playWhenReady = true - } + // Acquire shared player from pool, or create a safe fallback if unprovided + val trailerPlayer = remember(resolvedPool, resolvedToken) { + resolvedPool?.acquire(resolvedToken) ?: ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_OFF } + } + + // Configure playback when shouldPlay changes + LaunchedEffect(shouldPlay, resolvedVideoUrl, resolvedAudioUrl, trailerPlayer, volume, cropToFill, resolvedToken) { + val player = trailerPlayer ?: return@LaunchedEffect + val vUrl = resolvedVideoUrl - LaunchedEffect(volume) { + if (shouldPlay && !vUrl.isNullOrBlank()) { + if (resolvedPool != null && !resolvedPool.isCurrentOwner(resolvedToken)) { + return@LaunchedEffect + } + hasRenderedFirstFrame = false player.volume = volume.coerceIn(0f, 1f) - } + player.videoScalingMode = if (cropToFill) { + C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING + } else { + C.VIDEO_SCALING_MODE_SCALE_TO_FIT + } - LaunchedEffect(videoUrl, audioUrl, youtubeKey) { - val vUrl = videoUrl ?: return@LaunchedEffect - if (!audioUrl.isNullOrBlank()) { + val aUrl = resolvedAudioUrl + if (!aUrl.isNullOrBlank()) { val factory = DefaultMediaSourceFactory(YoutubeChunkedDataSourceFactory()) val videoSource = factory.createMediaSource(MediaItem.fromUri(vUrl)) - val audioSource = factory.createMediaSource(MediaItem.fromUri(audioUrl!!)) + val audioSource = factory.createMediaSource(MediaItem.fromUri(aUrl)) player.setMediaSource(MergingMediaSource(videoSource, audioSource)) } else { player.setMediaItem(MediaItem.fromUri(vUrl)) } player.prepare() + player.playWhenReady = true + } else { + hasRenderedFirstFrame = false + player.playWhenReady = false + // Immediate stop and clear on focus loss or cancellation — no delay + runCatching { + player.stop() + player.clearMediaItems() + } } + } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, player) { - val listener = object : Player.Listener { - override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - extractor.evictCache(youtubeKey) + DisposableEffect(lifecycleOwner, trailerPlayer, resolvedToken) { + val player = trailerPlayer ?: return@DisposableEffect onDispose {} + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + shouldPlay = false + currentOnPlayingChanged(false) } - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - shouldPlay = false - currentOnPlayingChanged(false) + } + + override fun onRenderedFirstFrame() { + hasRenderedFirstFrame = true + currentOnPlayingChanged(true) + currentOnFirstFrameRendered() + } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + resolvedKey?.let { extractor.evictCache(it) } + shouldPlay = false + currentOnPlayingChanged(false) + } + } + + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE, + Lifecycle.Event.ON_STOP -> { + player.playWhenReady = false + if (resolvedPool != null) { + resolvedPool.stop(resolvedToken) + } else { + runCatching { + player.stop() + player.clearMediaItems() + } } } - } - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_PAUSE -> player.pause() - Lifecycle.Event.ON_RESUME -> if (shouldPlay) player.play() - else -> {} + Lifecycle.Event.ON_RESUME -> { + if (shouldPlay && !resolvedVideoUrl.isNullOrBlank() && (resolvedPool == null || resolvedPool.isCurrentOwner(resolvedToken))) { + player.playWhenReady = true + } } + else -> Unit } - player.addListener(listener) - lifecycleOwner.lifecycle.addObserver(observer) - if (!lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - player.pause() - } - onDispose { + } + + player.addListener(listener) + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + try { lifecycleOwner.lifecycle.removeObserver(observer) + } catch (_: Throwable) {} + try { player.removeListener(listener) - currentOnPlayingChanged(false) - player.stop() - player.release() + } catch (_: Throwable) {} + currentOnPlayingChanged(false) + if (resolvedPool != null) { + resolvedPool.stop(resolvedToken) + } else { + try { + player.stop() + player.clearMediaItems() + player.release() + } catch (_: Throwable) {} } } + } + val playerAlphaState = animateFloatAsState( + targetValue = if (shouldPlay && hasRenderedFirstFrame) 1f else 0f, + animationSpec = tween(durationMillis = 400), + label = "trailerFirstFrameAlpha" + ) + + val zoomScale = if (cropToFill) overscanZoom.coerceAtLeast(1f) else 1f + + if (trailerPlayer != null && shouldPlay) { AndroidView( factory = { ctx -> (LayoutInflater.from(ctx).inflate(R.layout.trailer_player_view, null) as PlayerView).apply { - this.player = player - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + this.player = trailerPlayer + useController = false + setControllerAutoShow(false) + hideController() + isFocusable = false + isFocusableInTouchMode = false + descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS + resizeMode = if (cropToFill) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIT + } keepScreenOn = true } }, update = { view -> - if (view.player !== player) view.player = player + if (view.player !== trailerPlayer) { + view.player = trailerPlayer + } + view.resizeMode = if (cropToFill) { + AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIT + } + }, + onRelease = { view -> + view.player = null + view.keepScreenOn = false }, - modifier = Modifier.fillMaxSize().clipToBounds() + modifier = modifier + .fillMaxSize() + .clipToBounds() + .graphicsLayer { + alpha = playerAlphaState.value + scaleX = zoomScale + scaleY = zoomScale + } ) } } 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 1631d926a..d1266a311 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 @@ -137,6 +137,7 @@ 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.network.OkHttpProvider +import com.arflix.tv.core.player.TrailerPlayerPool import com.arflix.tv.ui.components.FeaturedMediaCard import com.arflix.tv.ui.components.movieGenreNameRes import com.arflix.tv.ui.components.tvGenreNameRes @@ -488,7 +489,16 @@ private suspend fun androidx.compose.foundation.lazy.LazyListState.animateHomeSc } } - +private fun calculateTvHeroMediaDimensions( + screenWidth: androidx.compose.ui.unit.Dp, + screenHeight: androidx.compose.ui.unit.Dp +): Pair { + val rowsViewportHeight = if ((screenHeight - 24.dp) < 600.dp) 238.dp else ((screenHeight - 24.dp) * 0.35f).coerceIn(260.dp, 340.dp) + val catalogPostersTop = screenHeight - rowsViewportHeight + 28.dp + val mediaHeight = catalogPostersTop.coerceAtLeast(320.dp) + val mediaWidth = (mediaHeight * (16f / 9f)).coerceIn(screenWidth * 0.58f, screenWidth * 0.65f) + return mediaWidth to mediaHeight +} @Composable private fun HomeBackdropCrossfade( @@ -569,6 +579,7 @@ private fun HomeBackdropCrossfade( model = request, contentDescription = null, contentScale = ContentScale.Crop, + alignment = Alignment.TopEnd, modifier = Modifier.fillMaxSize() ) } @@ -581,6 +592,7 @@ private fun HomeBackdropCrossfade( model = request, contentDescription = null, contentScale = ContentScale.Crop, + alignment = Alignment.TopEnd, onSuccess = { pendingBackdropReady = true }, modifier = Modifier .fillMaxSize() @@ -639,9 +651,31 @@ fun HomeScreen( val profileCount = if (currentProfile != null) 1 else 0 val usePosterCards = rememberCardLayoutMode() == CardLayoutMode.POSTER val lifecycleOwner = LocalLifecycleOwner.current + val trailerPlayerPool = com.arflix.tv.core.player.LocalTrailerPlayerPool.current + DisposableEffect(lifecycleOwner, trailerPlayerPool) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + trailerPlayerPool?.stop() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + trailerPlayerPool?.stop() + } + } + LaunchedEffect(uiState.trailerAutoPlay) { + if (!uiState.trailerAutoPlay) { + trailerPlayerPool?.stop() + } + } + LaunchedEffect(uiState.trailerInCards) { + trailerPlayerPool?.stop() + } var suppressSelectUntilMs by remember { mutableLongStateOf(0L) } val navigateToDetailsWithCache: (MediaType, Int, Int?, Int?) -> Unit = { mediaType, mediaId, initialSeason, initialEpisode -> + trailerPlayerPool?.stop() val matchingItem = uiState.categories.asSequence() .flatMap { it.items.asSequence() } .firstOrNull { it.id == mediaId && it.mediaType == mediaType } @@ -723,9 +757,17 @@ fun HomeScreen( } val density = LocalDensity.current val configuration = LocalConfiguration.current - val backdropSize = remember(configuration, density) { - val widthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() } - val heightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() } + val backdropSize = remember(configuration, density, isMobile) { + val (mediaWidthDp, mediaHeightDp) = if (isMobile) { + configuration.screenWidthDp.dp to configuration.screenHeightDp.dp + } else { + calculateTvHeroMediaDimensions( + screenWidth = configuration.screenWidthDp.dp, + screenHeight = configuration.screenHeightDp.dp + ) + } + val widthPx = with(density) { mediaWidthDp.roundToPx() } + val heightPx = with(density) { mediaHeightDp.roundToPx() } widthPx.coerceAtLeast(1) to heightPx.coerceAtLeast(1) } val backdropGradient = remember { @@ -867,6 +909,7 @@ fun HomeScreen( val now = SystemClock.elapsedRealtime() val isFastScrolling = now - focusState.lastNavEventTime < fastScrollThresholdMs if (isFastScrolling) { + trailerPlayerPool?.stop() delay(360L) if ( focusState.currentRowIndex != focusSnapshot.rowIndex || @@ -884,6 +927,7 @@ fun HomeScreen( if (homeRowItemKey(latestFocusedItem) != focusSnapshot.focusedItemKey) { return@collectLatest } + trailerPlayerPool?.stop() viewModel.onFocusChanged(focusSnapshot.rowIndex, focusSnapshot.itemIndex, shouldPrefetch = true) viewModel.updateHeroItem(latestFocusedItem) } @@ -945,17 +989,12 @@ fun HomeScreen( var isTrailerPlaying by remember { mutableStateOf(false) } var trailerSuppressed by remember { mutableStateOf(false) } - LaunchedEffect(displayHeroItem?.id) { trailerSuppressed = false } + LaunchedEffect(displayHeroItem?.id) { + trailerSuppressed = false + isTrailerPlaying = false + } val heroRowIsContinueWatching = latestDisplayCategories .getOrNull(focusState.currentRowIndex)?.id == "continue_watching" - val trailerOverlayAlpha = remember { Animatable(1f) } - LaunchedEffect(isTrailerPlaying) { - if (isTrailerPlaying) { - trailerOverlayAlpha.animateTo(0f, tween(1500, easing = FastOutSlowInEasing)) - } else { - trailerOverlayAlpha.animateTo(1f, tween(500, easing = FastOutSlowInEasing)) - } - } var heroPlaybackHandles by remember { mutableStateOf(null) } var preparedHeroVideoUrl by remember { mutableStateOf(null) } @@ -1064,20 +1103,21 @@ 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. + // On TV, render the hero media (backdrop image and trailer) strictly delimited in the top-right corner (16:9 ratio, ~60-65% screen) matching Nuvio design. if (!isMobile) { - val backdropModifier = Modifier.fillMaxSize() - Box(modifier = backdropModifier) { - if (!showCinematicHomeLayer || settledBackdrop == null) { - Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = backdropGradient - ) - ) - } + val (mediaWidth, mediaHeight) = remember(configuration) { + calculateTvHeroMediaDimensions( + screenWidth = configuration.screenWidthDp.dp, + screenHeight = configuration.screenHeightDp.dp + ) + } + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .width(mediaWidth) + .height(mediaHeight) + ) { if (showCinematicHomeLayer && settledBackdrop != null) { HomeBackdropCrossfade( backdropUrl = settledBackdrop, @@ -1086,6 +1126,43 @@ fun HomeScreen( ) } + // YouTube trailer auto-play as Hero Backdrop (trailerInCards == false) + val showHeroBackdropTrailer = !isMobile && + !uiState.trailerInCards && + uiState.trailerAutoPlay && + !trailerSuppressed && + !heroRowIsContinueWatching && + heroVideoUrl == null && + uiState.heroTrailerKey != null + + if (showHeroBackdropTrailer) { + var trailerFirstFrameRendered by remember(uiState.heroTrailerKey) { mutableStateOf(false) } + val trailerFadeAlpha by animateFloatAsState( + targetValue = if (trailerFirstFrameRendered) 1f else 0f, + animationSpec = tween(durationMillis = 400), + label = "heroTrailerBackdropAlpha" + ) + + TrailerPlayer( + youtubeKey = uiState.heroTrailerKey, + delayMs = uiState.trailerDelaySeconds * 1000L, + volume = if (uiState.trailerSoundEnabled) 1f else 0f, + cropToFill = true, + overscanZoom = 1.0f, + ownerToken = displayHeroItem?.let { "hero_backdrop_${it.mediaType}_${it.id}" } ?: "hero_backdrop", + trailerPlayerPool = trailerPlayerPool, + onPlayingChanged = { isPlaying -> + isTrailerPlaying = isPlaying + }, + onFirstFrameRendered = { + trailerFirstFrameRendered = true + }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = trailerFadeAlpha } + ) + } + if (heroExoPlayer != null && (heroVideoUrl != null || heroVideoAlpha > 0.01f)) { AndroidView( factory = { ctx -> @@ -1114,72 +1191,53 @@ fun HomeScreen( ) } - // YouTube trailer auto-play — on TV, trailer plays inside the focused card instead - if ((isMobile || !uiState.trailerInCards) && heroVideoUrl == null && uiState.trailerAutoPlay && uiState.heroTrailerKey != null && !trailerSuppressed && !heroRowIsContinueWatching) { - TrailerPlayer( - youtubeKey = uiState.heroTrailerKey!!, - delayMs = uiState.trailerDelaySeconds * 1000L, - volume = if (uiState.trailerSoundEnabled) 1f else 0f, - onPlayingChanged = { playing -> isTrailerPlaying = playing }, - modifier = Modifier.fillMaxSize() - ) - } - - // === SCRIM SYSTEM === + // === HERO MEDIA GRADIENTS (Smooth edge blend into dark background on left and bottom) === + val heroBgColor = appBackgroundDark() Box( modifier = Modifier .fillMaxSize() .drawWithCache { val width = size.width val height = size.height - val leftScrim = Brush.horizontalGradient( + val bgColor = heroBgColor + + val horizontalFadeEndX = width * 0.32f + val horizontalGradient = Brush.horizontalGradient( colorStops = arrayOf( - 0.0f to Color.Black.copy(alpha = 0.95f), - 0.12f to Color.Black.copy(alpha = 0.88f), - 0.22f to Color.Black.copy(alpha = 0.72f), - 0.32f to Color.Black.copy(alpha = 0.50f), - 0.42f to Color.Black.copy(alpha = 0.30f), - 0.55f to Color.Black.copy(alpha = 0.10f), - 0.65f to Color.Transparent, + 0.0f to bgColor, + 0.25f to bgColor.copy(alpha = 0.85f), + 0.55f to bgColor.copy(alpha = 0.50f), + 0.80f to bgColor.copy(alpha = 0.15f), 1.0f to Color.Transparent ), startX = 0f, - endX = width - ) - val topScrim = Brush.verticalGradient( - colorStops = arrayOf( - 0.0f to Color.Black.copy(alpha = 0.7f), - 0.06f to Color.Black.copy(alpha = 0.45f), - 0.15f to Color.Black.copy(alpha = 0.15f), - 0.25f to Color.Transparent, - 1.0f to Color.Transparent - ), - startY = 0f, - endY = height + endX = horizontalFadeEndX ) - val bottomScrim = Brush.verticalGradient( + + val bottomStripStartY = height * 0.82f + val verticalGradient = Brush.verticalGradient( colorStops = arrayOf( 0.0f to Color.Transparent, - 0.85f to Color.Transparent, - 0.92f to Color.Black.copy(alpha = 0.5f), - 1.0f to Color.Black.copy(alpha = 0.85f) + 0.40f to bgColor.copy(alpha = 0.35f), + 0.75f to bgColor.copy(alpha = 0.80f), + 1.0f to bgColor ), - startY = 0f, + startY = bottomStripStartY, endY = height ) + onDrawBehind { + // 1. Left horizontal edge fade to solid background drawRect( - brush = leftScrim, - size = Size(width * 0.66f, height) + brush = horizontalGradient, + topLeft = Offset(0f, 0f), + size = Size(horizontalFadeEndX, height) ) + // 2. Bottom vertical edge fade to solid background drawRect( - brush = topScrim, - size = Size(width, height * 0.26f) - ) - drawRect( - brush = bottomScrim, - topLeft = Offset(0f, height * 0.84f), - size = Size(width, height * 0.16f) + brush = verticalGradient, + topLeft = Offset(0f, bottomStripStartY), + size = Size(width, height - bottomStripStartY) ) } } @@ -1187,7 +1245,6 @@ fun HomeScreen( } } // end if (!isMobile) backdrop - Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = trailerOverlayAlpha.value }) { HomeInputLayer( categories = displayCategories, cardLogoUrls = cardLogoUrls, @@ -1258,16 +1315,15 @@ fun HomeScreen( featuredTrailerKey = if (!isMobile && uiState.trailerInCards && uiState.trailerAutoPlay && !trailerSuppressed && !heroRowIsContinueWatching) uiState.heroTrailerKey else null, featuredTrailerDelayMs = uiState.trailerDelaySeconds * 1000L, featuredTrailerVolume = if (uiState.trailerSoundEnabled) 1f else 0f, + trailerPlayerPool = trailerPlayerPool, onOpenContextMenu = { item, isContinue -> contextMenuItem = item contextMenuIsContinueWatching = isContinue showContextMenu = true } ) - } // end trailer-dim wrapper if (showCinematicHomeLayer) { - Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = trailerOverlayAlpha.value }) { HomeHeroLayer( heroItem = displayHeroItem, heroLogoUrl = displayHeroLogo, @@ -1280,7 +1336,6 @@ fun HomeScreen( isIptvItem = { item -> viewModel.isIptvItem(item) }, getIptvChannelId = { item -> viewModel.getIptvChannelId(item) } ) - } // end trailer-dim wrapper } // Error state - show message when loading failed and no content @@ -2363,6 +2418,7 @@ private fun HomeInputLayer( featuredTrailerKey: String? = null, featuredTrailerDelayMs: Long = 0L, featuredTrailerVolume: Float = 0f, + trailerPlayerPool: TrailerPlayerPool? = null, onOpenContextMenu: (MediaItem, Boolean) -> Unit, ) { val focusRequester = remember { FocusRequester() } @@ -2491,11 +2547,15 @@ private fun HomeInputLayer( if (isContextMenuOpen) { return@onPreviewKeyEvent false } - if (trailerIsPlaying && event.type == KeyEventType.KeyDown && - (isArvioDpadNavigationKey(event.key) || event.key == Key.Enter || event.key == Key.DirectionCenter || event.key == Key.Back) - ) { - onTrailerStop() - return@onPreviewKeyEvent true + if (trailerIsPlaying && event.type == KeyEventType.KeyDown) { + if (event.key == Key.Back || event.key == Key.Escape) { + onTrailerStop() + return@onPreviewKeyEvent true + } + if (isArvioDpadNavigationKey(event.key) || event.key == Key.Enter || event.key == Key.DirectionCenter) { + onTrailerStop() + // Do not consume the event: allows focus navigation to move immediately on first click + } } if (event.type == KeyEventType.KeyUp && isArvioDpadNavigationKey(event.key)) { dpadRepeatGate.reset() @@ -2793,6 +2853,7 @@ private fun HomeInputLayer( featuredTrailerKey = featuredTrailerKey, featuredTrailerDelayMs = featuredTrailerDelayMs, featuredTrailerVolume = featuredTrailerVolume, + trailerPlayerPool = trailerPlayerPool, onItemClick = { item -> if (!isActionableHomeItem(item)) { return@HomeRowsLayer @@ -2858,6 +2919,7 @@ private fun HomeRowsLayer( featuredTrailerKey: String? = null, featuredTrailerDelayMs: Long = 0L, featuredTrailerVolume: Float = 0f, + trailerPlayerPool: TrailerPlayerPool? = null, onItemClick: (MediaItem) -> Unit, onItemLongClick: ((MediaItem, Boolean) -> Unit)? = null ) { @@ -2905,6 +2967,7 @@ private fun HomeRowsLayer( featuredTrailerKey = featuredTrailerKey, featuredTrailerDelayMs = featuredTrailerDelayMs, featuredTrailerVolume = featuredTrailerVolume, + trailerPlayerPool = trailerPlayerPool, onItemClick = onItemClick ) } @@ -3167,6 +3230,7 @@ private fun TvHomeRowsLayer( featuredTrailerKey: String? = null, featuredTrailerDelayMs: Long = 0L, featuredTrailerVolume: Float = 0f, + trailerPlayerPool: TrailerPlayerPool? = null, onItemClick: (MediaItem) -> Unit ) { // ── Focus-row stabilizer ── @@ -3358,6 +3422,7 @@ private fun TvHomeRowsLayer( featuredTrailerKey = if (rowIsFocused) featuredTrailerKey else null, featuredTrailerDelayMs = featuredTrailerDelayMs, featuredTrailerVolume = featuredTrailerVolume, + trailerPlayerPool = trailerPlayerPool, onItemClick = onItemClick, onItemFocused = onRowItemFocused ) @@ -3573,6 +3638,7 @@ private fun ContentRow( featuredTrailerKey: String? = null, featuredTrailerDelayMs: Long = 0L, featuredTrailerVolume: Float = 0f, + trailerPlayerPool: TrailerPlayerPool? = null, onItemClick: (MediaItem) -> Unit, onItemFocused: (MediaItem, Int) -> Unit ) { @@ -3592,6 +3658,8 @@ private fun ContentRow( } val cardAspectRatio = if (effectivePosterMode) 2f / 3f else 16f / 9f val itemWidth = if (effectivePosterMode) 105.dp else 210.dp + val rowCardHeight = if (effectivePosterMode) (itemWidth / cardAspectRatio) else 146.dp + val expandedCardWidth = if (effectivePosterMode) 280.dp else 380.dp val itemSpacing = 14.dp val itemsToRender = remember(category.items) { if (category.items.isEmpty()) { @@ -3620,17 +3688,21 @@ private fun ContentRow( val itemSpanPx = remember(density, itemWidth, itemSpacing) { with(density) { (itemWidth + itemSpacing).toPx().coerceAtLeast(1f) } } - val hasFeaturedCard = !effectivePosterMode && featuredTrailerKey != null + val hasFeaturedCard = isCurrentRow && featuredTrailerKey != null // Tracks which item index has held focus long enough to expand. - // Using an index (not a boolean) means the derived `featuredExpanded` - // evaluates to false immediately in the same composition frame when - // focusedItemIndex changes — no async LaunchedEffect reset needed. - // Without this, the new card briefly saw featuredExpanded=true - // (stale from the previous card) and rendered at 380dp, causing a - // layout overshoot in the LazyRow before snapping back. - var featuredExpandedForIndex by remember { mutableIntStateOf(-1) } + // Tied strictly to focusedItemIndex so that D-pad navigation immediately resets it + // on the very same frame without stale expansion or delayed stopping. + var focusSettledForIndex by remember { mutableIntStateOf(-1) } + LaunchedEffect(focusedItemIndex, isCurrentRow, hasFeaturedCard) { + focusSettledForIndex = -1 + if (hasFeaturedCard && isCurrentRow && focusedItemIndex >= 0) { + val delayMs = if (featuredTrailerDelayMs <= 0L) 370L else featuredTrailerDelayMs.coerceAtLeast(370L) + delay(delayMs) + focusSettledForIndex = focusedItemIndex + } + } val featuredExpanded = hasFeaturedCard && isCurrentRow && - featuredExpandedForIndex == focusedItemIndex && focusedItemIndex >= 0 + focusSettledForIndex == focusedItemIndex && focusedItemIndex >= 0 val context = LocalContext.current val trailerExtractor = remember { EntryPointAccessors.fromApplication( @@ -3648,23 +3720,12 @@ private fun ContentRow( catch (_: Exception) {} } } - LaunchedEffect(focusedItemIndex, hasFeaturedCard) { - featuredExpandedForIndex = -1 - if (hasFeaturedCard && isCurrentRow && focusedItemIndex >= 0) { - delay(featuredTrailerDelayMs.coerceAtLeast(500L)) - featuredExpandedForIndex = focusedItemIndex - } - } val railFocusOverlayActive = isCurrentRow && isScrollable && focusedItemIndex >= 0 && totalItems > 0 && - !hasFeaturedCard && + !featuredExpanded && focusedItemIndex <= maxFirstIndex && focusedItemIndex == rowState.firstVisibleItemIndex && rowState.firstVisibleItemScrollOffset == 0 - val focusedCardIndex = if (railFocusOverlayActive) { - -1 - } else { - focusedItemIndex - } + val focusedCardIndex = focusedItemIndex val railFocusShape = rememberArvioCardShape(ArvioSkin.radius.md) val railEndPadding = lockedHomeRailEndPadding( itemWidth = itemWidth, @@ -3819,23 +3880,22 @@ private fun ContentRow( if (isRanked && index < 10) { val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] val rankedExpanded = hasFeaturedCard && itemIsFocused && featuredExpanded + val animatedRankedWidth by animateDpAsState( + targetValue = if (rankedExpanded) expandedCardWidth else itemWidth, + animationSpec = if (rankedExpanded) spring() else snap(), + label = "featuredRankedCardWidth" + ) if (rankedExpanded) { - // Expanded: fresh Animatable starting at itemWidth so the expansion - // animates in from the card's resting size. This branch is only entered - // after the 500ms focus-settle delay, so the Animatable is always new. - val expandAnim = remember { Animatable(itemWidth.value) } - LaunchedEffect(Unit) { - expandAnim.animateTo(380f, spring()) - } - val expandedWidth = expandAnim.value.dp - Box(modifier = Modifier.width(expandedWidth)) { + Box(modifier = Modifier.width(animatedRankedWidth)) { FeaturedMediaCard( item = item, - width = expandedWidth, - height = 146.dp, + width = animatedRankedWidth, + height = rowCardHeight, trailerKey = featuredTrailerKey, trailerDelayMs = 0L, trailerVolume = featuredTrailerVolume, + ownerToken = "${item.mediaType}_${item.id}", + trailerPlayerPool = trailerPlayerPool, onClick = onCardClick, ) TopRankRibbon( @@ -3885,7 +3945,7 @@ private fun ContentRow( val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] val cardExpanded = hasFeaturedCard && itemIsFocused && featuredExpanded val animatedCardWidth by animateDpAsState( - targetValue = if (cardExpanded) 380.dp else itemWidth, + targetValue = if (cardExpanded) expandedCardWidth else itemWidth, animationSpec = if (cardExpanded) spring() else snap(), label = "featuredCardWidth" ) @@ -3893,10 +3953,12 @@ private fun ContentRow( FeaturedMediaCard( item = item, width = animatedCardWidth, - height = 146.dp, + height = rowCardHeight, trailerKey = featuredTrailerKey, trailerDelayMs = 0L, trailerVolume = featuredTrailerVolume, + ownerToken = "${item.mediaType}_${item.id}", + trailerPlayerPool = trailerPlayerPool, onClick = onCardClick, ) } else { 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 9a6c5eafe..3e845d346 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 @@ -52,6 +52,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Job import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -1324,6 +1325,8 @@ class HomeViewModel @Inject constructor( // Debounce job for hero updates (Phase 6.1) private var heroUpdateJob: Job? = null private var heroDetailsJob: Job? = null + private var heroTrailerJob: Job? = null + private var prefetchTrailerJob: Job? = null private var prefetchJob: Job? = null private var preloadCategoryPriorityJob: Job? = null private val preloadCategoryJobs = ConcurrentHashMap() @@ -1782,6 +1785,14 @@ class HomeViewModel @Inject constructor( smoothScrolling = preferences.smoothScrolling ) + if (!preferences.trailerAutoPlay) { + heroTrailerJob?.cancel() + prefetchTrailerJob?.cancel() + if (_uiState.value.heroTrailerKey != null) { + _uiState.value = _uiState.value.copy(heroTrailerKey = null) + } + } + if (langChanged) { invalidateContentLanguageCaches() loadHomeData() @@ -4617,6 +4628,8 @@ class HomeViewModel @Inject constructor( // Phase 6.1 + 6.2-6.3: Adaptive debounce heroUpdateJob?.cancel() heroDetailsJob?.cancel() + heroTrailerJob?.cancel() + prefetchTrailerJob?.cancel() heroUpdateJob = viewModelScope.launch { if (debounceMs > 0) { delay(debounceMs) @@ -4681,29 +4694,39 @@ class HomeViewModel @Inject constructor( ) } - private fun hydrateHeroDetailsIfNeeded(item: MediaItem) { - if (!isActionableMediaItem(item) || isIptvItem(item) || isCollectionItem(item)) { + private fun loadTrailerForHero(item: MediaItem) { + if (!_uiState.value.trailerAutoPlay) { + heroTrailerJob?.cancel() + _uiState.value = _uiState.value.copy(heroTrailerKey = null) + return + } + if (_uiState.value.heroItem?.id == item.id && _uiState.value.heroTrailerKey != null) { return } - // Fetch trailer for new hero item; skip if already loaded for this item (prevents restart mid-play) - if (_uiState.value.trailerAutoPlay && - !(_uiState.value.heroItem?.id == item.id && _uiState.value.heroTrailerKey != null) - ) { - _uiState.value = _uiState.value.copy(heroTrailerKey = null) - viewModelScope.launch(networkDispatcher) { - try { - val trailerKey = mediaRepository.getTrailerKey(item.mediaType, item.id) - if (trailerKey != null && _uiState.value.heroItem?.id == item.id) { - _uiState.value = _uiState.value.copy(heroTrailerKey = trailerKey) - prefetchTrailerUrl(trailerKey) - } - } catch (e: Exception) { + heroTrailerJob?.cancel() + _uiState.value = _uiState.value.copy(heroTrailerKey = null) + heroTrailerJob = viewModelScope.launch(networkDispatcher) { + try { + val trailerKey = mediaRepository.getTrailerKey(item.mediaType, item.id) + if (isActive && trailerKey != null && _uiState.value.heroItem?.id == item.id) { + _uiState.value = _uiState.value.copy(heroTrailerKey = trailerKey) + prefetchTrailerUrl(trailerKey) + } + } catch (e: Exception) { if (e is CancellationException) throw e } - } + } + } + + private fun hydrateHeroDetailsIfNeeded(item: MediaItem) { + if (!isActionableMediaItem(item) || isIptvItem(item) || isCollectionItem(item)) { + return } + // Fetch trailer for new hero item with active job cancellation + loadTrailerForHero(item) + val normalizedOverview = item.overview.trim() val looksTruncated = normalizedOverview.endsWith("...") || normalizedOverview.length < 120 if ( @@ -4725,24 +4748,17 @@ class HomeViewModel @Inject constructor( applyHeroDetailsSnapshotIfCurrent(item, snapshot) snapshot.primaryNetworkLogo?.let { preloadLogoImages(listOf(it)) } - // Fetch trailer key for hero (YouTube) - try { - val trailerKey = mediaRepository.getTrailerKey(item.mediaType, item.id) - if (trailerKey != null && _uiState.value.heroItem?.id == item.id) { - _uiState.value = _uiState.value.copy(heroTrailerKey = trailerKey) - prefetchTrailerUrl(trailerKey) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } catch (e: Exception) { + // Fetch trailer key for hero (YouTube) if not yet resolved + loadTrailerForHero(item) + } catch (e: Exception) { if (e is CancellationException) throw e } } } private fun prefetchTrailerUrl(trailerKey: String) { - viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + prefetchTrailerJob?.cancel() + prefetchTrailerJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { runCatching { youTubeExtractor.extractPlaybackSource("https://www.youtube.com/watch?v=$trailerKey") } @@ -4752,23 +4768,8 @@ class HomeViewModel @Inject constructor( private fun scheduleHeroDetailsFetch(item: MediaItem, fastScrolling: Boolean) { heroDetailsJob?.cancel() - // Fetch trailer for new hero item; skip if already loaded for this item (prevents restart mid-play) - if (_uiState.value.trailerAutoPlay && - !(_uiState.value.heroItem?.id == item.id && _uiState.value.heroTrailerKey != null) - ) { - _uiState.value = _uiState.value.copy(heroTrailerKey = null) - viewModelScope.launch(networkDispatcher) { - try { - val trailerKey = mediaRepository.getTrailerKey(item.mediaType, item.id) - if (trailerKey != null && _uiState.value.heroItem?.id == item.id) { - _uiState.value = _uiState.value.copy(heroTrailerKey = trailerKey) - prefetchTrailerUrl(trailerKey) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - } - } - } + // Fetch trailer for new hero item with active job cancellation + loadTrailerForHero(item) heroDetailsJob = viewModelScope.launch(networkDispatcher) { val detailsKey = heroDetailsKey(item) 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 d658cd211..ce717e6ab 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 @@ -1723,6 +1723,14 @@ fun PlayerScreen( onDispose { playerEngine.release() } } + val trailerPlayerPool = com.arflix.tv.core.player.LocalTrailerPlayerPool.current + DisposableEffect(trailerPlayerPool) { + trailerPlayerPool?.yield() + onDispose { + trailerPlayerPool?.reclaim() + } + } + val exitTransition = rememberPlayerExitTransition( animateExit = deviceType.isTouchDevice(), pause = { if (!playerReleased) exoPlayer.pause() },