From bc4cb41a212a13dd7cb472e1e5286b0ccf0ec72b Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 16 Oct 2026 14:24:12 +0300 Subject: [PATCH 1/2] feat(trailers): bring back in-app trailer playback Replace the official YouTube player (WebView) with ARVIO's in-app trailer player everywhere trailers play: - Home: the hero background trailer and the expanding row card, including the poster-row case, where the hero plays the trailer. - Netflix Home: the billboard and the focused card. - Details: the trailer button opens a full-screen in-app player. The trailer's stream is looked up as soon as its key is known, so it usually starts right after the focus delay. Trailer auto-play is read from the profile's setting again. The official player components, their tests and the android-youtube-player dependency are removed. --- app/build.gradle.kts | 5 - app/proguard-rules.pro | 7 - .../HomeTrailerPreviewDeviceTest.kt | 211 ----- .../ui/components/YouTubeTrailerDeviceTest.kt | 99 -- .../home/HomeTrailerScreenDeviceTest.kt | 147 --- .../tv/data/api/TrailerPlaybackSource.kt | 6 + .../arflix/tv/data/api/YouTubeExtractor.kt | 609 ++++++++++++ .../api/YoutubeChunkedDataSourceFactory.kt | 145 +++ .../tv/ui/components/HomeTrailerPreview.kt | 131 --- .../com/arflix/tv/ui/components/MediaCard.kt | 79 +- .../tv/ui/components/OpenYouTubeTrailer.kt | 41 - .../arflix/tv/ui/components/TrailerPlayer.kt | 177 ++++ .../tv/ui/components/YouTubeTrailerModal.kt | 880 ------------------ .../tv/ui/screens/details/DetailsScreen.kt | 48 +- .../arflix/tv/ui/screens/home/HomeScreen.kt | 65 +- .../tv/ui/screens/home/HomeViewModel.kt | 11 + .../arflix/tv/ui/screens/home/NetflixHome.kt | 52 +- .../tv/ui/screens/settings/SettingsScreen.kt | 26 + .../ui/components/HomeTrailerPreviewTest.kt | 20 - .../tv/ui/components/YouTubeTrailerUrlTest.kt | 16 - 20 files changed, 1129 insertions(+), 1646 deletions(-) delete mode 100644 app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt delete mode 100644 app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt delete mode 100644 app/src/androidTest/kotlin/com/arflix/tv/ui/screens/home/HomeTrailerScreenDeviceTest.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/TrailerPlaybackSource.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/YouTubeExtractor.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/api/YoutubeChunkedDataSourceFactory.kt delete mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt delete mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/OpenYouTubeTrailer.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/TrailerPlayer.kt delete mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt delete mode 100644 app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt delete mode 100644 app/src/test/kotlin/com/arflix/tv/ui/components/YouTubeTrailerUrlTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 567556c6fb..6820647915 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -503,11 +503,6 @@ ksp { baselineProfile(project(":benchmark")) - // YouTube player for trailers (official IFrame API wrapper) - implementation("com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0") { - exclude(group = "androidx.lifecycle") - } - // NanoHTTPD – lightweight HTTP server for QR-based AI key setup implementation("org.nanohttpd:nanohttpd:2.3.1") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a2398d4c47..7c3e62ddcd 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -259,13 +259,6 @@ -keep class com.arflix.tv.util.UiState { *; } -keep class com.arflix.tv.util.UiState$* { *; } -# ============================================ -# android-youtube-player (IFrame API) -# ============================================ --keep class com.pierfrancescosoffritti.androidyoutubeplayer.** { *; } --keepnames class com.pierfrancescosoffritti.youtubeplayer.* { *; } --dontwarn com.pierfrancescosoffritti.androidyoutubeplayer.** - # sherpa-onnx (audio subtitle sync speech recognition): JNI reads config fields by name. -keep class com.k2fsa.sherpa.onnx.** { *; } -keep interface com.k2fsa.sherpa.onnx.** { *; } diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt deleted file mode 100644 index d1b8d89596..0000000000 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -package com.arflix.tv.ui.components - -import android.view.View -import android.view.ViewGroup -import android.webkit.WebView -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.test.core.app.ActivityScenario -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import com.arflix.tv.data.model.Category -import com.arflix.tv.data.model.MediaItem -import com.arflix.tv.ui.screens.home.HomeFocusState -import com.arflix.tv.ui.screens.home.HomeInputLayer -import com.arflix.tv.util.DeviceType -import com.arflix.tv.util.LocalDeviceType -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants.PlayerState -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView -import java.io.File -import java.util.concurrent.atomic.AtomicReference -import java.util.concurrent.atomic.AtomicInteger -import org.junit.Assert.* -import org.junit.Test - -class HomeTrailerPreviewDeviceTest { - @Test fun playerPreparesBeforeTheFocusDeadlineWithoutStartingEarly() { - val cuedAt = java.util.concurrent.atomic.AtomicLong(0L) - val playedAt = java.util.concurrent.atomic.AtomicLong(0L) - val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" - ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> - val deadline = android.os.SystemClock.elapsedRealtime() + 15_000L - scenario.onActivity { activity -> activity.setContent { - HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), autoplayNotBeforeMs = deadline, - onStateChange = { - if (it == PlayerState.VIDEO_CUED) cuedAt.compareAndSet(0L, android.os.SystemClock.elapsedRealtime()) - if (it == PlayerState.PLAYING) playedAt.compareAndSet(0L, android.os.SystemClock.elapsedRealtime()) - }) { Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) } - } } - await("Player did not prepare during the focus delay", 12_000) { cuedAt.get() > 0L } - assertTrue("Preparation waited for the autoplay deadline", cuedAt.get() < deadline) - assertEquals("Prepared trailer played too early", 0L, playedAt.get()) - var preparedView: YouTubePlayerView? = null - scenario.onActivity { - preparedView = findPlayer(it.window.decorView) - assertEquals(View.INVISIBLE, preparedView!!.visibility) - } - await("Prepared trailer did not autoplay", 40_000) { playedAt.get() > 0L } - assertTrue("Autoplay ignored the focus deadline", playedAt.get() >= deadline) - scenario.onActivity { - assertSame("Autoplay recreated the prepared player", preparedView, findPlayer(it.window.decorView)) - assertEquals(View.VISIBLE, preparedView!!.visibility) - assertEquals(1, webViews(it.window.decorView)) - } - } - } - - @Test fun endedTrailerRestoresArtworkWithoutLooping() { - val state = AtomicReference() - val ended = java.util.concurrent.atomic.AtomicBoolean(false) - val videoDuration = AtomicReference(0f) - val player = AtomicReference() - val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" - ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> - scenario.onActivity { activity -> activity.setContent { - HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), onStateChange = { - state.set(it) - if (it == PlayerState.ENDED) ended.set(true) - }) { Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) } - } } - await("Preview did not mount") { count(scenario) == 1 } - scenario.onActivity { findPlayer(it.window.decorView)!!.addYouTubePlayerListener(object : AbstractYouTubePlayerListener() { - override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { - player.set(youTubePlayer) - videoDuration.set(duration) - } - }) } - await("Trailer did not start with its duration", 60_000) { state.get() == PlayerState.PLAYING && videoDuration.get() > 0f } - scenario.onActivity { player.get().seekTo(videoDuration.get() - 0.1f) } - await("Ended trailer did not return to artwork", 20_000) { ended.get() && count(scenario) == 0 } - Thread.sleep(1500) - assertEquals("Ended preview restarted itself", 0, count(scenario)) - } - } - - @Test fun playingCardKeepsRemoteNavigationAndSinglePressSelection() { - val focus = HomeFocusState().apply { userHasNavigated = true } - val opened = AtomicInteger(-1) - val state = AtomicReference() - val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" - ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> - scenario.onActivity { activity -> activity.setContent { - CompositionLocalProvider(LocalDeviceType provides DeviceType.TV) { - HomeInputLayer(categories = listOf(Category("trending_movies", "Movies", (1..10).map { MediaItem(it, "Title $it") })), - cardLogoUrls = emptyMap(), focusState = focus, limitRowsDuringStartup = false, - suppressSelectUntilMs = 0L, contentStartPadding = 24.dp, fastScrollThresholdMs = 80L, - usePosterCards = false, isContextMenuOpen = false, currentProfile = null, - onNavigateToDetails = { _, id, _, _ -> opened.set(id) }, onNavigateToCollection = {}, - onNavigateToSearch = {}, onNavigateToWatchlist = {}, onNavigateToTv = { _, _ -> }, - onNavigateToSettings = {}, onSwitchProfile = {}, onExitApp = {}, onOpenContextMenu = { _, _ -> }, - featuredTrailerKey = video, featuredTrailerDelayMs = 500L) - } - } } - val first = observePlayer(scenario, state) - await("Focused card did not play", 60_000) { state.get() == PlayerState.PLAYING } - scenario.onActivity { assertEquals(View.VISIBLE, first.visibility); assertEquals(1, webViews(it.window.decorView)) } - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - device.pressDPadRight() - await("Playing trailer stole D-pad navigation") { focus.currentItemIndex == 1 } - state.set(null) - val next = observePlayer(scenario, state, first) - await("Next focused card did not play", 60_000) { state.get() == PlayerState.PLAYING } - scenario.onActivity { assertEquals(View.VISIBLE, next.visibility); assertEquals(1, webViews(it.window.decorView)) } - device.pressDPadCenter() - await("Select needed more than one press") { opened.get() == 2 } - } - } - - @Test fun rendererFailureFallsBackWithoutClosingTheHost() { - val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" - val state = AtomicReference() - ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> - scenario.onActivity { activity -> activity.setContent { - HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), onStateChange = state::set) { - Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) - } - } } - // Start playback first: a startup timeout must not satisfy recovery. - await("Preview did not start before the crash test", 60_000) { state.get() == PlayerState.PLAYING } - scenario.onActivity { findWebView(it.window.decorView)!!.loadUrl("chrome://crash") } - // Android may finish its crash dump before delivering the callback. - await("Dead renderer was retained", 60_000) { count(scenario) == 0 } - scenario.onActivity { assertFalse(it.isFinishing); assertFalse(it.isDestroyed) } - } - } - - @Test fun inlineAutoplayReleasesOnFocusLossAndBackground() { - val state = AtomicReference() - val enabled = mutableStateOf(true) - val sawCue = java.util.concurrent.atomic.AtomicBoolean(false) - val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" - ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> - lateinit var host: ComponentActivity - scenario.onActivity { activity -> - host = activity - activity.setContent { - HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), delayMs = 500, - enabled = enabled.value, onStateChange = { android.util.Log.i("HomeTrailerDevice", "Inline state: $it"); if (it == PlayerState.VIDEO_CUED) sawCue.set(true); state.set(it) }) { - Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) - } - } - } - await("Inline trailer did not play", 60_000) { state.get() == PlayerState.PLAYING } - assertTrue("Playback started without first preparing its thumbnail", sawCue.get()) - scenario.onActivity { assertEquals(1, webViews(it.window.decorView)); assertEquals(View.VISIBLE, findPlayer(it.window.decorView)!!.visibility) } - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val output = File(host.getExternalFilesDir(null), "trailer-test").apply { mkdirs() } - device.takeScreenshot(File(output, "home-inline-playing.png")) - scenario.moveToState(Lifecycle.State.CREATED) - await("Background preview retained its WebView") { count(scenario) == 0 } - state.set(null) - scenario.moveToState(Lifecycle.State.RESUMED) - await("Returning did not restore the visible preview", 60_000) { state.get() == PlayerState.PLAYING } - scenario.onActivity { enabled.value = false } - await("Focus loss retained a playing WebView") { count(scenario) == 0 } - scenario.onActivity { assertFalse(it.isFinishing) } - device.takeScreenshot(File(output, "home-inline-released.png")) - } - } - - private fun count(scenario: ActivityScenario): Int { - var result = -1 - scenario.onActivity { result = webViews(it.window.decorView) } - return result - } - private fun webViews(view: View): Int = if (view is WebView) 1 else - if (view is ViewGroup) (0 until view.childCount).sumOf { webViews(view.getChildAt(it)) } else 0 - private fun findWebView(view: View): WebView? = if (view is WebView) view else - if (view is ViewGroup) (0 until view.childCount).firstNotNullOfOrNull { findWebView(view.getChildAt(it)) } else null - private fun findPlayer(view: View): YouTubePlayerView? = if (view is YouTubePlayerView) view else - if (view is ViewGroup) (0 until view.childCount).firstNotNullOfOrNull { findPlayer(view.getChildAt(it)) } else null - private fun observePlayer(scenario: ActivityScenario, playbackState: AtomicReference, previous: YouTubePlayerView? = null): YouTubePlayerView { - var result: YouTubePlayerView? = null - await("Focused preview was not mounted") { - scenario.onActivity { result = findPlayer(it.window.decorView) } - result != null && result !== previous - } - scenario.onActivity { - result!!.addYouTubePlayerListener(object : AbstractYouTubePlayerListener() { - override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerState) { playbackState.set(state) } - override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { if (second > 0f) playbackState.set(PlayerState.PLAYING) } - }) - } - return result!! - } - private fun await(message: String, timeoutMs: Long = 10_000, predicate: () -> Boolean) { - val deadline = System.currentTimeMillis() + timeoutMs - while (!predicate() && System.currentTimeMillis() < deadline) Thread.sleep(100) - assertTrue(message, predicate()) - } -} diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt deleted file mode 100644 index b20326f9f2..0000000000 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.arflix.tv.ui.components - -import android.util.Log -import android.view.View -import android.view.ViewGroup -import android.webkit.WebView -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.Lifecycle -import androidx.test.core.app.ActivityScenario -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import com.arflix.tv.util.DeviceType -import com.arflix.tv.util.LocalDeviceType -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants.PlayerState -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.File -import java.util.concurrent.atomic.AtomicReference - -/** Live integration test using real frame clocks and YouTube playback events. */ -class YouTubeTrailerDeviceTest { - @Test fun playerLoadsAndBackClosesWithoutLeavingHost() { - val args = InstrumentationRegistry.getArguments() - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - device.wakeUp() - val tv = args.getString("trailerDevice") == "tv" - val state = AtomicReference() - val shown = mutableStateOf(true) - ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> - lateinit var host: ComponentActivity - scenario.onActivity { activity -> - host = activity - activity.setContent { - CompositionLocalProvider(LocalDeviceType provides if (tv) DeviceType.TV else DeviceType.PHONE) { - if (shown.value) YouTubeTrailerModal( - youtubeKey = args.getString("trailerVideo") ?: "M7lc1UVf-VE", - onPlaybackStateChanged = { - Log.i("TrailerDeviceTest", "Playback state: $it") - state.set(it) - }, - onClose = { shown.value = false } - ) - } - } - } - val output = File(host.getExternalFilesDir(null), "trailer-test").apply { mkdirs() } - val label = if (tv) "tv" else "phone" - try { - await("YouTube did not start playing", 60000) { state.get() == PlayerState.PLAYING } - Thread.sleep(2000) - device.takeScreenshot(File(output, "$label.png")) - if (tv) { - device.pressDPadCenter() - await("Remote OK did not pause playback") { state.get() == PlayerState.PAUSED } - scenario.moveToState(Lifecycle.State.CREATED) - scenario.moveToState(Lifecycle.State.RESUMED) - Thread.sleep(2000) - assertTrue("A deliberate pause must survive returning to the app", state.get() == PlayerState.PAUSED) - device.takeScreenshot(File(output, "$label-paused-after-return.png")) - device.pressDPadCenter() - await("Remote OK did not resume playback") { state.get() == PlayerState.PLAYING } - device.pressDPadUp() - Thread.sleep(500) - scenario.onActivity { - assertTrue("YouTube controls must accept remote focus", findWebView(it.window.decorView)?.hasFocus() == true) - } - device.pressDPadRight() - device.takeScreenshot(File(output, "$label-native-controls.png")) - - } - val holdMs = args.getString("trailerHoldMs")?.toLongOrNull() ?: 0L - if (holdMs > 0) Thread.sleep(holdMs.coerceAtMost(180000)) - device.pressBack() - await("Back did not close the trailer") { !shown.value } - scenario.onActivity { assertFalse("Back must preserve the host screen", it.isFinishing) } - } finally { - device.takeScreenshot(File(output, "$label-final.png")) - } - } - } - - private fun await(message: String, timeoutMs: Long = 10000, predicate: () -> Boolean) { - val deadline = System.currentTimeMillis() + timeoutMs - while (!predicate() && System.currentTimeMillis() < deadline) Thread.sleep(100) - assertTrue(message, predicate()) - } - - private fun findWebView(view: View): WebView? { - if (view is WebView) return view - if (view is ViewGroup) for (i in 0 until view.childCount) { - findWebView(view.getChildAt(i))?.let { return it } - } - return null - } -} diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/home/HomeTrailerScreenDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/home/HomeTrailerScreenDeviceTest.kt deleted file mode 100644 index 741314c854..0000000000 --- a/app/src/androidTest/kotlin/com/arflix/tv/ui/screens/home/HomeTrailerScreenDeviceTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.arflix.tv.ui.screens.home - -import android.view.View -import android.view.ViewGroup -import android.webkit.WebView -import androidx.activity.compose.setContent -import androidx.compose.runtime.CompositionLocalProvider -import androidx.lifecycle.ViewModelProvider -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.test.core.app.ActivityScenario -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.UiDevice -import com.arflix.tv.MainActivity -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.util.DeviceType -import com.arflix.tv.util.LocalDeviceType -import com.arflix.tv.util.settingsDataStore -import com.arflix.tv.util.profilesDataStore -import java.io.File -import java.util.concurrent.atomic.AtomicReference -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants.PlayerState -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.After -import org.junit.Test - -/** Real HomeScreen + real trailer metadata, without signing in to a customer account. */ -class HomeTrailerScreenDeviceTest { - private var restoreLayout: () -> Unit = {} - @After fun restorePreferences() { restoreLayout() } - - @Test fun homeMetadataLoadsAnInlinePlayerForTheFocusedTitle() { - val instrumentation = InstrumentationRegistry.getInstrumentation() - val context = instrumentation.targetContext - val poster = InstrumentationRegistry.getArguments().getString("trailerPoster") == "true" - val suffix = if (poster) "-poster" else "" - if (poster) { - val profileId = runBlocking { context.profilesDataStore.data.first()[stringPreferencesKey("active_profile_id")] }.orEmpty().ifBlank { "default" } - val layoutKey = stringPreferencesKey("profile_${profileId}_catalogue_row_layout_home:trending_movies") - val previous = runBlocking { context.settingsDataStore.data.first()[layoutKey] } - restoreLayout = { runBlocking { context.settingsDataStore.edit { if (previous == null) it.remove(layoutKey) else it[layoutKey] = previous } }; Unit } - runBlocking { context.settingsDataStore.edit { it[layoutKey] = "Poster" } } - } - val movies = listOf( - MediaItem(872585, "Oppenheimer", mediaType = MediaType.MOVIE, - backdrop = "https://image.tmdb.org/t/p/w1280/fm6KqXpk3M2HVveHwCrBSSBaO0V.jpg", - image = "https://image.tmdb.org/t/p/w500/ptpr0kGAckfQkJeJIt8st5dglvd.jpg"), - MediaItem(157336, "Interstellar", mediaType = MediaType.MOVIE), - MediaItem(693134, "Dune: Part Two", mediaType = MediaType.MOVIE) - ) - val categories = listOf(Category("trending_movies", "Trending Movies", movies)) - ActivityScenario.launch(MainActivity::class.java).use { scenario -> - lateinit var vm: HomeViewModel - lateinit var host: MainActivity - scenario.onActivity { activity -> - host = activity - vm = ViewModelProvider(activity)[HomeViewModel::class.java] - activity.setContent { - CompositionLocalProvider(LocalDeviceType provides DeviceType.TV) { - HomeScreen(viewModel = vm, preloadedCategories = categories, preloadedHeroItem = movies.first()) - } - } - } - await("Home did not resolve a trailer", 45_000) { !vm.uiState.value.heroTrailerKey.isNullOrBlank() } - await("Home did not mount its inline player", 20_000) { - var count = 0 - scenario.onActivity { count = webViews(it.window.decorView) } - count == 1 - } - val output = File(host.getExternalFilesDir(null), "trailer-test").apply { mkdirs() } - capturePreparedPreview(scenario, output, "home-real-screen$suffix.png") - val previousHeroId = vm.uiState.value.heroItem?.id - UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressDPadRight() - await("Home focus did not move to the next title", 10_000) { vm.uiState.value.heroItem?.id != previousHeroId } - await("Next focused title did not resolve its trailer", 30_000) { !vm.uiState.value.heroTrailerKey.isNullOrBlank() } - await("Next focused preview was not mounted", 20_000) { count(scenario) == 1 } - capturePreparedPreview(scenario, output, "home-real-next-card$suffix.png") - val profileId = runBlocking { host.profilesDataStore.data.first()[stringPreferencesKey("active_profile_id")] }.orEmpty().ifBlank { "default" } - val cardsKey = booleanPreferencesKey("profile_${profileId}_trailer_in_cards") - val previous = runBlocking { host.settingsDataStore.data.first()[cardsKey] } - try { - runBlocking { host.settingsDataStore.edit { it[cardsKey] = false } } - await("Hero mode preference did not apply", 10_000) { !vm.uiState.value.trailerInCards } - await("Hero mode did not mount one player", 20_000) { - var count = 0 - scenario.onActivity { count = webViews(it.window.decorView) } - count == 1 - } - scenario.onActivity { host -> - val player = findPlayer(host.window.decorView)!! - val density = host.resources.displayMetrics.density - assertTrue("Hero viewport below YouTube minimum after pixel rounding", player.width / density >= 200f && player.height / density >= 200f) - } - capturePreparedPreview(scenario, output, "home-real-hero$suffix.png") - } finally { - runBlocking { host.settingsDataStore.edit { if (previous == null) it.remove(cardsKey) else it[cardsKey] = previous } } - } - } - } - private fun webViews(view: View): Int = if (view is WebView) 1 else - if (view is ViewGroup) (0 until view.childCount).sumOf { webViews(view.getChildAt(it)) } else 0 - private fun findPlayer(view: View): YouTubePlayerView? = if (view is YouTubePlayerView) view else - if (view is ViewGroup) (0 until view.childCount).firstNotNullOfOrNull { findPlayer(view.getChildAt(it)) } else null - private fun count(scenario: ActivityScenario): Int { - var count = 0 - scenario.onActivity { count = webViews(it.window.decorView) } - return count - } - private fun capturePreparedPreview(scenario: ActivityScenario, output: File, filename: String) { - val playbackState = AtomicReference() - val currentSecond = AtomicReference(0f) - scenario.onActivity { host -> - findPlayer(host.window.decorView)?.addYouTubePlayerListener(object : AbstractYouTubePlayerListener() { - override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerState) { playbackState.set(state) } - override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { currentSecond.set(second) } - }) - } - // A loaded WebView alone is not proof of playback. Capture a decoded - // frame, or the restored artwork if YouTube declines this public title. - await("Preview remained stuck instead of playing or restoring artwork", 30_000) { - currentSecond.get() > 5f || count(scenario) == 0 - } - val played = currentSecond.get() > 1f - scenario.onActivity { host -> - assertFalse(host.isFinishing) - if (played) { - assertEquals(1, webViews(host.window.decorView)) - assertEquals(View.VISIBLE, findPlayer(host.window.decorView)!!.visibility) - } - } - android.util.Log.i("HomeTrailerScreen", "$filename: ${if (played) "live playback" else "artwork fallback"}, state=${playbackState.get()}") - UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).takeScreenshot(File(output, filename)) - } - private fun await(message: String, timeoutMs: Long, predicate: () -> Boolean) { - val deadline = System.currentTimeMillis() + timeoutMs - while (!predicate() && System.currentTimeMillis() < deadline) Thread.sleep(100) - assertTrue(message, predicate()) - } -} diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/TrailerPlaybackSource.kt b/app/src/main/kotlin/com/arflix/tv/data/api/TrailerPlaybackSource.kt new file mode 100644 index 0000000000..1e78d1fce2 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/TrailerPlaybackSource.kt @@ -0,0 +1,6 @@ +package com.arflix.tv.data.api + +data class TrailerPlaybackSource( + val videoUrl: String, + val audioUrl: String? = null +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/YouTubeExtractor.kt b/app/src/main/kotlin/com/arflix/tv/data/api/YouTubeExtractor.kt new file mode 100644 index 0000000000..e80ba3d0bc --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/YouTubeExtractor.kt @@ -0,0 +1,609 @@ +package com.arflix.tv.data.api + +import android.net.Uri +import android.util.Log +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URL +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "InAppYouTubeExtractor" +private const val EXTRACTOR_TIMEOUT_MS = 30_000L +private const val URL_CACHE_TTL_MS = 5 * 60_000L +private const val WATCH_CONFIG_TTL_MS = 24 * 60 * 60_000L // 24h — key rarely changes + +// Known stable InnerTube API key for Android clients. Used as primary to skip the watch page +// GET (which can take 3-5s). Falls back to scraping the watch page if this ever gets rejected. +private const val FALLBACK_INNERTUBE_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" +private const val DEFAULT_USER_AGENT = + "Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" +private const val PREFERRED_SEPARATE_CLIENT = "android_vr" + + + + + + +private data class YouTubeClient( + val key: String, + val id: String, + val version: String, + val userAgent: String, + val context: Map, + val priority: Int +) + +private data class WatchConfig( + val apiKey: String?, + val visitorData: String? +) + +private data class StreamCandidate( + val client: String, + val priority: Int, + val url: String, + val score: Double, + val hasN: Boolean, + val itag: String, + val height: Int, + val fps: Int, + val ext: String +) + +private data class ManifestBestVariant( + val url: String, + val width: Int, + val height: Int, + val bandwidth: Long +) + +private data class ManifestCandidate( + val client: String, + val priority: Int, + val manifestUrl: String, + val selectedVariantUrl: String, + val height: Int, + val bandwidth: Long +) + +private val DEFAULT_HEADERS = mapOf( + "accept-language" to "en-US,en;q=0.9", + "user-agent" to DEFAULT_USER_AGENT +) + +private val CLIENTS = listOf( + YouTubeClient( + key = "android_vr", + id = "28", + version = "1.56.21", + userAgent = "com.google.android.apps.youtube.vr.oculus/1.56.21 " + + "(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip", + context = mapOf( + "clientName" to "ANDROID_VR", + "clientVersion" to "1.56.21", + "deviceMake" to "Oculus", + "deviceModel" to "Quest 3", + "osName" to "Android", + "osVersion" to "12", + "platform" to "MOBILE", + "androidSdkVersion" to 32, + "hl" to "en", + "gl" to "US" + ), + priority = 0 + ), + YouTubeClient( + key = "android", + id = "3", + version = "20.10.35", + userAgent = "com.google.android.youtube/20.10.35 (Linux; U; Android 14; en_US) gzip", + context = mapOf( + "clientName" to "ANDROID", + "clientVersion" to "20.10.35", + "osName" to "Android", + "osVersion" to "14", + "platform" to "MOBILE", + "androidSdkVersion" to 34, + "hl" to "en", + "gl" to "US" + ), + priority = 1 + ), + YouTubeClient( + key = "ios", + id = "5", + version = "20.10.1", + userAgent = "com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)", + context = mapOf( + "clientName" to "IOS", + "clientVersion" to "20.10.1", + "deviceModel" to "iPhone16,2", + "osName" to "iPhone", + "osVersion" to "17.4.0.21E219", + "platform" to "MOBILE", + "hl" to "en", + "gl" to "US" + ), + priority = 2 + ) +) + +@Singleton +class InAppYouTubeExtractor @Inject constructor() { + private val gson = Gson() + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(20, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + + // Result cache: videoId -> (source, fetchedAtMs). Cleared on error so stale 403 URLs don't stick. + private val urlCache = ConcurrentHashMap>() + + // WatchConfig cache: INNERTUBE_API_KEY is global and rarely changes + private val watchConfigMutex = Mutex() + private var cachedWatchConfig: WatchConfig? = null + private var watchConfigFetchedAt: Long = 0L + + suspend fun extractPlaybackSource(youtubeUrl: String): TrailerPlaybackSource? = withContext(Dispatchers.IO) { + if (youtubeUrl.isBlank()) return@withContext null + val videoId = extractVideoId(youtubeUrl) ?: return@withContext null + + val cached = urlCache[videoId] + if (cached != null && System.currentTimeMillis() - cached.second < URL_CACHE_TTL_MS) { + return@withContext cached.first + } + + val source = try { + withTimeout(EXTRACTOR_TIMEOUT_MS) { + extractPlaybackSourceInternal(videoId) + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + Log.w(TAG, "[$videoId] extraction timed out") + null + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + + Log.w(TAG, "[$videoId] extraction failed: ${e.message}") + null + } + + if (source != null) { + urlCache[videoId] = source to System.currentTimeMillis() + } else { + Log.w(TAG, "[$videoId] no playable source found") + } + source + } + + private suspend fun extractPlaybackSourceInternal(videoId: String): TrailerPlaybackSource? { + val watchConfig = getWatchConfig() + val apiKey = watchConfig.apiKey + ?: throw IllegalStateException("Unable to extract INNERTUBE_API_KEY") + + val progressive = mutableListOf() + val adaptiveVideo = mutableListOf() + val adaptiveAudio = mutableListOf() + val manifestUrls = mutableListOf>() + + // Parallel client API calls + var keyRejected = false + coroutineScope { + val clientJobs = CLIENTS.map { client -> + async(Dispatchers.IO) { + try { + val playerResponse = fetchPlayerResponse( + apiKey = apiKey, + videoId = videoId, + client = client, + visitorData = watchConfig.visitorData, + cookieHeader = null + ) + client to playerResponse + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + + val msg = e.message.orEmpty() + if (msg.contains("401") || msg.contains("403")) keyRejected = true + null + } + } + } + + clientJobs.awaitAll().filterNotNull().forEach { (client, playerResponse) -> + val streamingData = playerResponse.mapValue("streamingData") ?: return@forEach + val hlsManifestUrl = streamingData.stringValue("hlsManifestUrl") + if (!hlsManifestUrl.isNullOrBlank()) { + synchronized(manifestUrls) { manifestUrls += Triple(client.key, client.priority, hlsManifestUrl) } + } + + for (format in streamingData.listMapValue("formats")) { + val url = format.stringValue("url") ?: continue + val mimeType = format.stringValue("mimeType").orEmpty() + if (!mimeType.contains("video/") && mimeType.isNotBlank()) continue + val height = (format.numberValue("height") + ?: parseQualityLabel(format.stringValue("qualityLabel"))?.toDouble() ?: 0.0).toInt() + val fps = (format.numberValue("fps") ?: 0.0).toInt() + val bitrate = format.numberValue("bitrate") ?: format.numberValue("averageBitrate") ?: 0.0 + synchronized(progressive) { + progressive += StreamCandidate( + client = client.key, priority = client.priority, url = url, + score = videoScore(height, fps, bitrate), hasN = hasNParam(url), + itag = format.stringValue("itag").orEmpty(), height = height, fps = fps, + ext = if (mimeType.contains("webm")) "webm" else "mp4" + ) + } + } + + for (format in streamingData.listMapValue("adaptiveFormats")) { + val url = format.stringValue("url") ?: continue + val mimeType = format.stringValue("mimeType").orEmpty() + if (mimeType.contains("video/")) { + val height = (format.numberValue("height") + ?: parseQualityLabel(format.stringValue("qualityLabel"))?.toDouble() ?: 0.0).toInt() + val fps = (format.numberValue("fps") ?: 0.0).toInt() + val bitrate = format.numberValue("bitrate") ?: format.numberValue("averageBitrate") ?: 0.0 + synchronized(adaptiveVideo) { + adaptiveVideo += StreamCandidate( + client = client.key, priority = client.priority, url = url, + score = videoScore(height, fps, bitrate), hasN = hasNParam(url), + itag = format.stringValue("itag").orEmpty(), height = height, fps = fps, + ext = if (mimeType.contains("webm")) "webm" else "mp4" + ) + } + } else if (mimeType.contains("audio/") || mimeType.startsWith("audio/")) { + val bitrate = format.numberValue("bitrate") ?: format.numberValue("averageBitrate") ?: 0.0 + val asr = format.numberValue("audioSampleRate") ?: 0.0 + synchronized(adaptiveAudio) { + adaptiveAudio += StreamCandidate( + client = client.key, priority = client.priority, url = url, + score = audioScore(bitrate, asr), hasN = hasNParam(url), + itag = format.stringValue("itag").orEmpty(), height = 0, fps = 0, + ext = if (mimeType.contains("webm")) "webm" else "m4a" + ) + } + } + } + } + } + + // If hardcoded key was rejected and we got nothing, scrape a fresh key and retry once + val noStreams = manifestUrls.isEmpty() && progressive.isEmpty() && adaptiveVideo.isEmpty() + if (noStreams && keyRejected) { + Log.w(TAG, "[$videoId] hardcoded key rejected with no streams — retrying with scraped key") + refreshWatchConfigFromPage(videoId) + return extractPlaybackSourceInternal(videoId) + } + + if (manifestUrls.isEmpty() && progressive.isEmpty() && adaptiveVideo.isEmpty() && adaptiveAudio.isEmpty()) { + return null + } + + var bestManifest: ManifestCandidate? = null + if (manifestUrls.isNotEmpty()) { + coroutineScope { + val manifestJobs = manifestUrls.map { (clientKey, priority, manifestUrl) -> + async(Dispatchers.IO) { + try { + val variant = parseHlsManifest(manifestUrl) ?: return@async null + ManifestCandidate( + client = clientKey, priority = priority, + manifestUrl = manifestUrl, selectedVariantUrl = variant.url, + height = variant.height, bandwidth = variant.bandwidth + ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { null } + } + } + manifestJobs.awaitAll().filterNotNull().forEach { candidate -> + if (bestManifest == null || candidate.height > bestManifest!!.height || + (candidate.height == bestManifest!!.height && candidate.bandwidth > bestManifest!!.bandwidth) + ) { + bestManifest = candidate + } + } + } + } + + // Prefer HLS manifest — ExoPlayer handles it natively (adaptive bitrate, no n-param 403). + // Direct adaptive stream URLs require n-param decryption which we don't do, so they 403. + if (bestManifest != null) { + return TrailerPlaybackSource(videoUrl = bestManifest.manifestUrl, audioUrl = null) + } + + // Fall back to progressive (combined video+audio, also not n-param throttled at lower quality) + val bestProgressive = sortCandidates(progressive).firstOrNull() + if (bestProgressive != null) { + return TrailerPlaybackSource(videoUrl = bestProgressive.url, audioUrl = null) + } + + // Last resort: adaptive streams (may 403 without n-param decryption) + val bestVideo = pickBestForClient(adaptiveVideo, PREFERRED_SEPARATE_CLIENT) + val bestAudio = pickBestForClient(adaptiveAudio, PREFERRED_SEPARATE_CLIENT) + val videoUrl = bestVideo?.url ?: return null + return TrailerPlaybackSource(videoUrl = videoUrl, audioUrl = bestAudio?.url) + } + + private suspend fun getWatchConfig(): WatchConfig { + return watchConfigMutex.withLock { + val now = System.currentTimeMillis() + val cached = cachedWatchConfig + if (cached != null && now - watchConfigFetchedAt < WATCH_CONFIG_TTL_MS) { + return@withLock cached + } + val config = WatchConfig(apiKey = FALLBACK_INNERTUBE_KEY, visitorData = null) + cachedWatchConfig = config + watchConfigFetchedAt = now + config + } + } + + fun evictCache(videoId: String) { + urlCache.remove(videoId) + } + + suspend fun refreshWatchConfigFromPage(videoId: String) { + watchConfigMutex.withLock { + val watchResponse = performRequest( + url = "https://www.youtube.com/watch?v=$videoId&hl=en", + method = "GET", headers = DEFAULT_HEADERS + ) + if (watchResponse.ok) { + val fresh = parseWatchConfig(watchResponse.body) + if (fresh.apiKey != null) { + cachedWatchConfig = fresh + watchConfigFetchedAt = System.currentTimeMillis() + } + } + } + } + + private fun parseWatchConfig(html: String): WatchConfig { + val apiKey = YouTubeExtractorRegexes.API_KEY_REGEX.find(html)?.groupValues?.getOrNull(1) + val visitorData = YouTubeExtractorRegexes.VISITOR_DATA_REGEX.find(html)?.groupValues?.getOrNull(1) + return WatchConfig(apiKey = apiKey, visitorData = visitorData) + } + + private fun extractVideoId(input: String): String? { + val trimmed = input.trim() + if (YouTubeExtractorRegexes.VIDEO_ID_REGEX.matches(trimmed)) return trimmed + + val normalized = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://$trimmed" + } + + return try { + val uri = Uri.parse(normalized) + val host = uri.host?.lowercase().orEmpty() + if (host.endsWith("youtu.be")) { + val id = uri.pathSegments.firstOrNull() + if (!id.isNullOrBlank() && YouTubeExtractorRegexes.VIDEO_ID_REGEX.matches(id)) { + return id + } + } + val queryId = uri.getQueryParameter("v") + if (!queryId.isNullOrBlank() && YouTubeExtractorRegexes.VIDEO_ID_REGEX.matches(queryId)) { + return queryId + } + + val segments = uri.pathSegments + if (segments.size >= 2) { + val first = segments[0] + val second = segments[1] + if ((first == "embed" || first == "shorts" || first == "live") && YouTubeExtractorRegexes.VIDEO_ID_REGEX.matches(second)) { + return second + } + } + null + } catch (_: IllegalArgumentException) { + null + } catch (_: UnsupportedOperationException) { + null + } + } + + private fun fetchPlayerResponse( + apiKey: String, videoId: String, client: YouTubeClient, + visitorData: String?, cookieHeader: String? + ): Map<*, *> { + val endpoint = "https://www.youtube.com/youtubei/v1/player?key=${Uri.encode(apiKey)}" + val headers = buildMap { + putAll(DEFAULT_HEADERS) + put("content-type", "application/json") + put("origin", "https://www.youtube.com") + put("x-youtube-client-name", client.id) + put("x-youtube-client-version", client.version) + put("user-agent", client.userAgent) + if (!visitorData.isNullOrBlank()) put("x-goog-visitor-id", visitorData) + if (!cookieHeader.isNullOrBlank()) put("cookie", cookieHeader) + } + val payload = mapOf( + "videoId" to videoId, + "contentCheckOk" to true, + "racyCheckOk" to true, + "context" to mapOf("client" to client.context), + "playbackContext" to mapOf( + "contentPlaybackContext" to mapOf("html5Preference" to "HTML5_PREF_WANTS") + ) + ) + val response = performRequest(url = endpoint, method = "POST", headers = headers, body = gson.toJson(payload)) + if (!response.ok) { + throw IllegalStateException("player API ${client.key} failed (${response.status}): ${response.body.take(200)}") + } + return gson.fromJson(response.body, Map::class.java) ?: emptyMap() + } + + private fun parseHlsManifest(manifestUrl: String): ManifestBestVariant? { + val response = performRequest(url = manifestUrl, method = "GET", headers = DEFAULT_HEADERS) + if (!response.ok) throw IllegalStateException("Failed to fetch HLS manifest (${response.status})") + + val lines = response.body.lineSequence().map { it.trim() }.filter { it.isNotBlank() }.toList() + var bestVariant: ManifestBestVariant? = null + + for (i in lines.indices) { + val line = lines[i] + if (!line.startsWith("#EXT-X-STREAM-INF:")) continue + val attrs = parseHlsAttributeList(line) + val nextLine = lines.getOrNull(i + 1) ?: continue + if (nextLine.startsWith("#")) continue + val (width, height) = parseResolution(attrs["RESOLUTION"].orEmpty()) + val bandwidth = attrs["BANDWIDTH"]?.toLongOrNull() ?: 0L + val candidate = ManifestBestVariant(url = absolutizeUrl(manifestUrl, nextLine), width = width, height = height, bandwidth = bandwidth) + if (bestVariant == null || candidate.height > bestVariant.height || + (candidate.height == bestVariant.height && candidate.bandwidth > bestVariant.bandwidth) || + (candidate.height == bestVariant.height && candidate.bandwidth == bestVariant.bandwidth && candidate.width > bestVariant.width) + ) { + bestVariant = candidate + } + } + return bestVariant + } + + private fun parseHlsAttributeList(line: String): Map { + val index = line.indexOf(':') + if (index == -1) return emptyMap() + val raw = line.substring(index + 1) + val out = LinkedHashMap() + val key = StringBuilder(); val value = StringBuilder() + var inKey = true; var inQuote = false + for (ch in raw) { + if (inKey) { if (ch == '=') inKey = false else key.append(ch); continue } + if (ch == '"') { inQuote = !inQuote; continue } + if (ch == ',' && !inQuote) { + val k = key.toString().trim() + if (k.isNotEmpty()) out[k] = value.toString().trim() + key.clear(); value.clear(); inKey = true; continue + } + value.append(ch) + } + val lastKey = key.toString().trim() + if (lastKey.isNotEmpty()) out[lastKey] = value.toString().trim() + return out + } + + private fun parseResolution(raw: String): Pair { + val parts = raw.split('x') + if (parts.size != 2) return 0 to 0 + return (parts[0].toIntOrNull() ?: 0) to (parts[1].toIntOrNull() ?: 0) + } + + private fun parseQualityLabel(label: String?): Int? { + if (label.isNullOrBlank()) return null + val match = YouTubeExtractorRegexes.QUALITY_LABEL_REGEX.find(label) ?: return null + return match.groupValues.getOrNull(1)?.toIntOrNull() + } + + private fun hasNParam(url: String): Boolean = + try { + !Uri.parse(url).getQueryParameter("n").isNullOrBlank() + } catch (_: IllegalArgumentException) { + false + } catch (_: UnsupportedOperationException) { + false + } + + private fun videoScore(height: Int, fps: Int, bitrate: Double) = + height * 1_000_000_000.0 + fps * 1_000_000.0 + bitrate + + private fun audioScore(bitrate: Double, audioSampleRate: Double) = + bitrate * 1_000_000.0 + audioSampleRate + + private fun sortCandidates(items: List): List = + items.sortedWith( + compareByDescending { it.score } + .thenBy { if (it.hasN) 1 else 0 } + .thenBy { containerPreference(it.ext) } + .thenBy { it.priority } + ) + + private fun containerPreference(ext: String) = when (ext.lowercase()) { + "mp4", "m4a" -> 0; "webm" -> 1; else -> 2 + } + + private fun pickBestForClient(items: List, clientKey: String): StreamCandidate? { + val sameClient = items.filter { it.client == clientKey } + return sortCandidates(if (sameClient.isNotEmpty()) sameClient else items).firstOrNull() + } + + private fun absolutizeUrl(baseUrl: String, maybeRelative: String): String = + try { URL(URL(baseUrl), maybeRelative).toString() } catch (e: java.net.MalformedURLException) { maybeRelative } + + private fun performRequest(url: String, method: String, headers: Map, body: String? = null): RequestResponse { + val requestBuilder = Request.Builder().url(url).headers(buildHeaders(headers)) + when (method.uppercase()) { + "POST" -> requestBuilder.post((body ?: "").toRequestBody()) + "PUT" -> requestBuilder.put((body ?: "").toRequestBody()) + "DELETE" -> requestBuilder.delete() + else -> requestBuilder.get() + } + httpClient.newCall(requestBuilder.build()).execute().use { response -> + return RequestResponse( + ok = response.isSuccessful, status = response.code, + statusText = response.message, url = response.request.url.toString(), + body = response.body?.string().orEmpty() + ) + } + } + + private fun buildHeaders(source: Map): Headers { + val headers = Headers.Builder() + source.forEach { (name, value) -> + if (!name.equals("Accept-Encoding", ignoreCase = true)) headers.add(name, value) + } + if (source.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + headers.add("User-Agent", DEFAULT_USER_AGENT) + } + return headers.build() + } +} + +private data class RequestResponse( + val ok: Boolean, val status: Int, val statusText: String, val url: String, val body: String +) + +private fun Map<*, *>.mapValue(key: String): Map<*, *>? = this[key] as? Map<*, *> + +private fun Map<*, *>.listMapValue(key: String): List> { + val raw = this[key] as? List<*> ?: return emptyList() + return raw.mapNotNull { it as? Map<*, *> } +} + +private fun Map<*, *>.stringValue(key: String): String? = this[key]?.toString() + +private fun Map<*, *>.numberValue(key: String): Double? = when (val v = this[key]) { + is Number -> v.toDouble() + is String -> v.toDoubleOrNull() + else -> null +} + +private object YouTubeExtractorRegexes { + val VIDEO_ID_REGEX = Regex("^[a-zA-Z0-9_-]{11}$") + val API_KEY_REGEX = Regex("\"INNERTUBE_API_KEY\":\"([^\"]+)\"") + val VISITOR_DATA_REGEX = Regex("\"VISITOR_DATA\":\"([^\"]+)\"") + val QUALITY_LABEL_REGEX = Regex("(\\d{2,4})p") +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/YoutubeChunkedDataSourceFactory.kt b/app/src/main/kotlin/com/arflix/tv/data/api/YoutubeChunkedDataSourceFactory.kt new file mode 100644 index 0000000000..5cbaa72049 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/api/YoutubeChunkedDataSourceFactory.kt @@ -0,0 +1,145 @@ +package com.arflix.tv.data.api + +import android.net.Uri +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.TransferListener + +/** + * A DataSource.Factory that wraps DefaultHttpDataSource and appends YouTube's + * `&range=start-end` query parameter on each request. YouTube throttles (and + * kills) connections that try to download full adaptive streams in one shot, + * but honours chunked range-param requests at full speed. + * + * Only activates for googlevideo.com URLs; all other URLs pass through untouched. + */ +@UnstableApi +class YoutubeChunkedDataSourceFactory( + private val chunkSizeBytes: Long = CHUNK_SIZE +) : DataSource.Factory { + + companion object { + private const val TAG = "YTChunkedDS" + /** 10 MB chunks – large enough to avoid too many requests, small enough to dodge throttle. */ + private const val CHUNK_SIZE = 10L * 1024 * 1024 + } + + override fun createDataSource(): DataSource { + val upstream = DefaultHttpDataSource.Factory() + .setConnectTimeoutMs(15_000) + .setReadTimeoutMs(15_000) + .setAllowCrossProtocolRedirects(true) + .createDataSource() + return YoutubeChunkedDataSource(upstream, chunkSizeBytes) + } + + private class YoutubeChunkedDataSource( + private val upstream: DefaultHttpDataSource, + private val chunkSize: Long + ) : DataSource { + + private var currentUri: Uri? = null + private var isYouTubeStream = false + private var totalContentLength = C.LENGTH_UNSET.toLong() + private var currentChunkStart = 0L + private var currentChunkEnd = 0L + private var bytesReadInChunk = 0L + private var originalDataSpec: DataSpec? = null + + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val uri = dataSpec.uri + val host = uri.host.orEmpty() + isYouTubeStream = host.contains("googlevideo.com") + + if (!isYouTubeStream) { + return upstream.open(dataSpec) + } + + originalDataSpec = dataSpec + currentChunkStart = dataSpec.position + totalContentLength = dataSpec.length + + return openNextChunk() + } + + private fun openNextChunk(): Long { + val spec = originalDataSpec ?: throw IllegalStateException("No DataSpec") + val end = if (totalContentLength != C.LENGTH_UNSET.toLong()) { + minOf(currentChunkStart + chunkSize - 1, currentChunkStart + totalContentLength - 1) + } else { + currentChunkStart + chunkSize - 1 + } + currentChunkEnd = end + + // Append &range=start-end to the URL (YouTube's own range param, not HTTP Range header) + val rangedUri = spec.uri.buildUpon() + .appendQueryParameter("range", "$currentChunkStart-$currentChunkEnd") + .build() + + val chunkedSpec = spec.buildUpon() + .setUri(rangedUri) + .setPosition(0) // position within this chunk's response + .setLength(C.LENGTH_UNSET.toLong()) // let the server decide + .build() + + bytesReadInChunk = 0 + upstream.open(chunkedSpec) + return if (totalContentLength != C.LENGTH_UNSET.toLong()) totalContentLength else C.LENGTH_UNSET.toLong() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + if (!isYouTubeStream) { + return upstream.read(buffer, offset, length) + } + + val bytesRead = upstream.read(buffer, offset, length) + if (bytesRead == C.RESULT_END_OF_INPUT) { + // Current chunk exhausted — open the next one + val chunkBytesReceived = bytesReadInChunk + upstream.close() + + // If this chunk returned fewer bytes than requested, the stream is done + if (chunkBytesReceived < (currentChunkEnd - currentChunkStart + 1)) { + return C.RESULT_END_OF_INPUT + } + + currentChunkStart += chunkBytesReceived + if (totalContentLength != C.LENGTH_UNSET.toLong()) { + totalContentLength -= chunkBytesReceived + if (totalContentLength <= 0) { + return C.RESULT_END_OF_INPUT + } + } + + return try { + openNextChunk() + upstream.read(buffer, offset, length) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + + Log.w(TAG, "Failed to open next chunk at $currentChunkStart: ${e.message}") + C.RESULT_END_OF_INPUT + } + } + + bytesReadInChunk += bytesRead + return bytesRead + } + + override fun getUri(): Uri? = upstream.uri ?: currentUri + + override fun close() { + upstream.close() + currentUri = null + originalDataSpec = null + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt deleted file mode 100644 index 653359ecff..0000000000 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.arflix.tv.ui.components - -import android.graphics.Rect -import android.os.SystemClock -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.platform.LocalDensity -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer -import kotlinx.coroutines.delay - -/** Documented YouTube autoplay constraints, in CSS pixels (Android dp). */ -internal fun canAutoplayHomeTrailer(width: Float, height: Float, visibleArea: Float): Boolean = - width >= 200f && height >= 200f && visibleArea > width * height * 0.5f - -/** - * One unobscured official embed in the active Home preview slot. Moving focus - * removes the slot; backgrounding/scrolling it off screen releases the WebView. - * Artwork is a replacement, never a layer drawn in front of the player. - */ -@Composable -internal fun HomeTrailerPreview( - youtubeKey: String?, - modifier: Modifier = Modifier, - delayMs: Long = 0L, - autoplayNotBeforeMs: Long? = null, - volume: Float = 0f, - enabled: Boolean = true, - onStateChange: (PlayerConstants.PlayerState) -> Unit = {}, - fallback: @Composable () -> Unit -) { - val lifecycle = LocalLifecycleOwner.current.lifecycle - val view = LocalView.current - val density = LocalDensity.current.density - var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) } - var visible by remember { mutableStateOf(false) } - var settled by remember(youtubeKey, enabled) { mutableStateOf(false) } - var finished by remember(youtubeKey) { mutableStateOf(false) } - var started by remember(youtubeKey) { mutableStateOf(false) } - val latestStateCallback by rememberUpdatedState(onStateChange) - DisposableEffect(lifecycle) { - val observer = LifecycleEventObserver { _, _ -> - foreground = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) - } - lifecycle.addObserver(observer) - onDispose { lifecycle.removeObserver(observer) } - } - LaunchedEffect(youtubeKey, enabled, foreground, visible) { - settled = false - if (!youtubeKey.isNullOrBlank() && enabled && foreground && visible) { - // Stabilize the slot, then cue without playing while the focus - // delay is still running. Network preparation need not wait for it. - delay(150L) - settled = true - } - } - var playbackAllowed by remember(youtubeKey, enabled) { mutableStateOf(false) } - LaunchedEffect(youtubeKey, enabled, foreground, visible, delayMs, autoplayNotBeforeMs) { - playbackAllowed = false - if (!youtubeKey.isNullOrBlank() && enabled && foreground && visible) { - val remainingDelay = autoplayNotBeforeMs?.let { it - SystemClock.elapsedRealtime() } ?: delayMs - delay(remainingDelay.coerceAtLeast(0L)) - playbackAllowed = true - } - } - val preparePlayer = settled && enabled && foreground && visible && !finished && !youtubeKey.isNullOrBlank() - // Cue only while preparing. Artwork stays visible until YouTube has its - // own thumbnail ready; playback starts after the unobscured embed is shown. - var cued by remember(youtubeKey, preparePlayer) { mutableStateOf(false) } - var player by remember(youtubeKey, preparePlayer) { mutableStateOf(null) } - val revealPlayer = preparePlayer && cued && playbackAllowed - LaunchedEffect(revealPlayer, player) { - if (revealPlayer) { - withFrameNanos { } - player?.play() - } - } - LaunchedEffect(preparePlayer, playbackAllowed, youtubeKey) { - if (preparePlayer && playbackAllowed) { - started = false - // An unavailable/blocked embed must not leave an empty preview forever. - delay(20_000L) - if (!started) finished = true - } - } - Box(modifier.onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - val window = Rect().also { view.getWindowVisibleDisplayFrame(it) } - val intersectionWidth = (minOf(bounds.right, window.right.toFloat()) - maxOf(bounds.left, window.left.toFloat())).coerceAtLeast(0f) - val intersectionHeight = (minOf(bounds.bottom, window.bottom.toFloat()) - maxOf(bounds.top, window.top.toFloat())).coerceAtLeast(0f) - visible = canAutoplayHomeTrailer( - coordinates.size.width / density, coordinates.size.height / density, - intersectionWidth * intersectionHeight / (density * density) - ) - }) { - if (!revealPlayer) fallback() - if (preparePlayer) { - key(youtubeKey) { - TrailerPlayerSurface( - youtubeKey = youtubeKey!!, - modifier = Modifier.fillMaxSize(), - showControls = false, - visible = revealPlayer, - onReady = { readyPlayer -> - player = readyPlayer - if (volume <= 0f) readyPlayer.mute() else { - readyPlayer.unMute() - readyPlayer.setVolume((volume * 100).toInt().coerceIn(0, 100)) - } - false - }, - onStateChange = { state -> - if (state == PlayerConstants.PlayerState.VIDEO_CUED) cued = true - if (state == PlayerConstants.PlayerState.PLAYING) started = true - if (state == PlayerConstants.PlayerState.ENDED) finished = true - latestStateCallback(state) - }, - onError = { finished = true } - ) - } - } - } -} 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 d1fc3f183b..098dc18d4b 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 @@ -784,26 +784,65 @@ fun FeaturedMediaCard( } } - Column(Modifier.width(width).testTag("home_trailer_card_${item.mediaType}_${item.id}")) { - HomeTrailerPreview( - youtubeKey = trailerKey, - // A spring can briefly cross its target before overshooting. Wait - // for a stable size so that crossing cannot create/release WebViews. - delayMs = trailerDelayMs, - autoplayNotBeforeMs = autoplayNotBeforeMs, - volume = trailerVolume, - // The player is mounted only once expansion has reached its final size. - enabled = kotlin.math.abs(width.value - artworkWidth.value) < 0.5f, - modifier = Modifier.fillMaxWidth().height(height).background(Color.Black) - ) { - if (imageUrl != null) { - AsyncImage( - model = imageUrl, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clickable(onClick = onClick) - ) - } + val shape = rememberArvioCardShape(ArvioSkin.radius.md) + // Remaining focus delay, fixed when the card opens so recompositions don't restart it. + val startDelayMs = remember(item.id, item.mediaType, trailerKey) { + autoplayNotBeforeMs?.let { (it - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(0L) } + ?: trailerDelayMs + } + // The player is mounted only once expansion has reached its final size. + val expanded = kotlin.math.abs(width.value - artworkWidth.value) < 0.5f + + ArvioFocusableSurface( + modifier = Modifier.size(width, height).testTag("home_trailer_card_${item.mediaType}_${item.id}"), + shape = shape, + backgroundColor = Color(0xFF1A1A1A), + outlineColor = ArvioSkin.colors.focusOutline, + outlineWidth = 2.5.dp, + focusedScale = 1f, + pressedScale = 0.97f, + animateFocus = false, + enableSystemFocus = false, + isFocusedOverride = true, + onClick = onClick, + ) { _ -> + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) } + if (trailerKey != null && expanded) { + TrailerPlayer( + youtubeKey = trailerKey, + delayMs = startDelayMs, + volume = trailerVolume, + modifier = Modifier.fillMaxSize() + ) + } + // Bottom gradient so title text is readable over the backdrop/trailer + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 0.55f to Color.Transparent, + 1f to Color.Black.copy(alpha = 0.85f) + ) + ) + ) + Text( + text = item.title, + style = ArvioSkin.typography.cardTitle, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 10.dp, vertical = 8.dp) + ) } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/OpenYouTubeTrailer.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/OpenYouTubeTrailer.kt deleted file mode 100644 index 42f4cbc321..0000000000 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/OpenYouTubeTrailer.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.arflix.tv.ui.components - -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.Uri -import android.widget.Toast -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import com.arflix.tv.R - -private val youtubeKeyRegex = Regex("[A-Za-z0-9_-]{11}") - -internal fun youtubeTrailerUrl(key: String): String? = - key.takeIf { it.matches(youtubeKeyRegex) } - ?.let { "https://www.youtube.com/watch?v=$it" } - -/** Keep YouTube's own playback surface, including controls and rights restrictions. */ -@Composable -fun OpenYouTubeTrailer(youtubeKey: String, onFinished: () -> Unit) { - val context = LocalContext.current - LaunchedEffect(youtubeKey) { - try { - val url = youtubeTrailerUrl(youtubeKey) - if (url == null) { - Toast.makeText(context, R.string.trailer_open_failed, Toast.LENGTH_LONG).show() - } else { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) - } - } catch (_: ActivityNotFoundException) { - Toast.makeText(context, R.string.trailer_open_failed, Toast.LENGTH_LONG).show() - } catch (_: SecurityException) { - Toast.makeText(context, R.string.trailer_open_failed, Toast.LENGTH_LONG).show() - } finally { - // Recomposition after returning from YouTube must not relaunch the trailer. - onFinished() - } - } -} 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 new file mode 100644 index 0000000000..b2f6218e03 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/TrailerPlayer.kt @@ -0,0 +1,177 @@ +package com.arflix.tv.ui.components + +import android.view.LayoutInflater +import androidx.compose.animation.AnimatedVisibility +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 +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +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.data.api.InAppYouTubeExtractor +import com.arflix.tv.data.api.YoutubeChunkedDataSourceFactory +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +/** + * Muted YouTube trailer player using ExoPlayer with direct YouTube stream extraction. + * 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. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface TrailerPlayerEntryPoint { + fun inAppYouTubeExtractor(): InAppYouTubeExtractor +} + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@Composable +fun TrailerPlayer( + youtubeKey: String, + modifier: Modifier = Modifier, + delayMs: Long = 0L, + volume: Float = 0f, + onPlayingChanged: (Boolean) -> Unit = {} +) { + val context = LocalContext.current + val currentOnPlayingChanged by rememberUpdatedState(onPlayingChanged) + + var shouldPlay by remember { mutableStateOf(false) } + var videoUrl by remember { mutableStateOf(null) } + var audioUrl by remember { mutableStateOf(null) } + + val entryPoint = remember { + EntryPointAccessors.fromApplication(context, TrailerPlayerEntryPoint::class.java) + } + val extractor = remember { entryPoint.inAppYouTubeExtractor() } + + LaunchedEffect(youtubeKey) { + shouldPlay = false + videoUrl = null + audioUrl = null + delay(delayMs) + withContext(Dispatchers.IO) { + try { + val source = extractor.extractPlaybackSource("https://www.youtube.com/watch?v=$youtubeKey") + if (source != null) { + videoUrl = source.videoUrl + audioUrl = source.audioUrl + } + } catch (_: Exception) {} + } + if (videoUrl != null) { + 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 + } + } + + LaunchedEffect(volume) { + player.volume = volume.coerceIn(0f, 1f) + } + + LaunchedEffect(videoUrl, audioUrl, youtubeKey) { + val vUrl = videoUrl ?: return@LaunchedEffect + if (!audioUrl.isNullOrBlank()) { + val factory = DefaultMediaSourceFactory(YoutubeChunkedDataSourceFactory()) + val videoSource = factory.createMediaSource(MediaItem.fromUri(vUrl)) + val audioSource = factory.createMediaSource(MediaItem.fromUri(audioUrl!!)) + player.setMediaSource(MergingMediaSource(videoSource, audioSource)) + } else { + player.setMediaItem(MediaItem.fromUri(vUrl)) + } + player.prepare() + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, player) { + val listener = object : Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + extractor.evictCache(youtubeKey) + } + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + shouldPlay = false + currentOnPlayingChanged(false) + } + } + } + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> player.pause() + Lifecycle.Event.ON_RESUME -> if (shouldPlay) player.play() + else -> {} + } + } + player.addListener(listener) + lifecycleOwner.lifecycle.addObserver(observer) + if (!lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + player.pause() + } + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + player.removeListener(listener) + currentOnPlayingChanged(false) + player.stop() + player.release() + } + } + + AndroidView( + factory = { ctx -> + (LayoutInflater.from(ctx).inflate(R.layout.trailer_player_view, null) as PlayerView).apply { + this.player = player + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + keepScreenOn = true + } + }, + update = { view -> + if (view.player !== player) view.player = player + }, + modifier = Modifier.fillMaxSize().clipToBounds() + ) + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt deleted file mode 100644 index c63a526235..0000000000 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt +++ /dev/null @@ -1,880 +0,0 @@ -package com.arflix.tv.ui.components - -import android.content.Intent -import android.content.res.Configuration -import android.net.Uri -import android.os.SystemClock -import android.view.ViewGroup -import android.view.View -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FastForward -import androidx.compose.material.icons.filled.FastRewind -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.zIndex -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.arflix.tv.R -import com.arflix.tv.ui.screens.player.common.PlayerSystemBarsEffect -import com.arflix.tv.ui.skin.ArvioSkin -import com.arflix.tv.util.LocalDeviceType -import com.arflix.tv.util.findActivity -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions -import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView -import kotlinx.coroutines.delay - -/** - * Height of the TV control strip below the video. - * - * The strip is reserved permanently, even while it looks empty: the modal - * background is black, so an empty strip is invisible, and the video never has - * to change size when our bar appears or hides. - */ -private val tvStripHeight = 64.dp - -/** Space reserved for our close button on mobile. Never sits over the player. */ -private val mobileCloseSlot = 72.dp - -private fun formatTime(seconds: Float): String { - val totalSec = seconds.toInt().coerceAtLeast(0) - val minutes = totalSec / 60 - val remSec = totalSec % 60 - return "%02d:%02d".format(minutes, remSec) -} - -private enum class IndicatorType { - PLAY, PAUSE, SEEK_BACK, SEEK_FORWARD -} - -private data class TransientIndicator( - val type: IndicatorType, - val text: String, - val icon: ImageVector, - val timestamp: Long = SystemClock.uptimeMillis() -) - -/** - * The embedded player itself. Nothing is ever drawn on top of this. - * - * YouTube owns its controls on every device. TV shortcuts are handled by a - * separate focus target; entering the WebView leaves its key handling intact. - */ -@Composable -internal fun TrailerPlayerSurface( - youtubeKey: String, - modifier: Modifier = Modifier, - showControls: Boolean = true, - visible: Boolean = true, - onViewReady: (YouTubePlayerView) -> Unit = {}, - onReady: (YouTubePlayer) -> Boolean = { true }, - onStateChange: (PlayerConstants.PlayerState) -> Unit = {}, - onSecond: (Float) -> Unit = {}, - onDuration: (Float) -> Unit = {}, - onError: () -> Unit = {}, - onReleased: () -> Unit = {} -) { - val onReadyCb by rememberUpdatedState(onReady) - val onStateChangeCb by rememberUpdatedState(onStateChange) - val onSecondCb by rememberUpdatedState(onSecond) - val onDurationCb by rememberUpdatedState(onDuration) - val onErrorCb by rememberUpdatedState(onError) - val onReleasedCb by rememberUpdatedState(onReleased) - val currentKey by rememberUpdatedState(youtubeKey) - // Issue 3: persistent-instance primitive. The WebView is created once by the - // factory below; video switches while it is alive go through cueVideo() in - // `update` instead of tearing the renderer down and cold-starting a new one. - var boundPlayer by remember { mutableStateOf(null) } - var loadedKey by remember { mutableStateOf(null) } - // Remembers the init-time autoplay decision so a mid-modal key switch - // starts the new playback lifecycle the same way (load vs cue). - var autoplayOnReady by remember { mutableStateOf(null) } - var rendererGone by remember { mutableStateOf(false) } - var disposed by remember { mutableStateOf(false) } - - AndroidView( - factory = { ctx -> - YouTubePlayerView(ctx).apply { - visibility = if (visible) View.VISIBLE else View.INVISIBLE - disposed = false - rendererGone = false - enableAutomaticInitialization = false - installTrailerWebViewRecovery(this) { - rendererGone = true - boundPlayer = null - loadedKey = null - if (!disposed) onErrorCb() - } - - // Switches off the embedding library's own overlay UI, which - // would itself sit on top of the player. - setCustomPlayerUi(android.view.View(ctx).apply { visibility = android.view.View.GONE }) - - // Leave the embedded controls, captions and links reachable on TV too. - descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS - onViewReady(this) - - val iFrameOptions = IFramePlayerOptions.Builder(ctx) - .controls(if (showControls) 1 else 0) - .rel(0) - .ivLoadPolicy(3) - .ccLoadPolicy(0) - .fullscreen(0) - .build() - - initialize( - object : AbstractYouTubePlayerListener() { - override fun onReady(youTubePlayer: YouTubePlayer) { - if (disposed || rendererGone) return - boundPlayer = youTubePlayer - loadedKey = currentKey - val autoplay = onReadyCb(youTubePlayer) - autoplayOnReady = autoplay - if (autoplay) { - youTubePlayer.loadVideo(currentKey, 0f) - } else { - youTubePlayer.cueVideo(currentKey, 0f) - } - } - - override fun onStateChange( - youTubePlayer: YouTubePlayer, - state: PlayerConstants.PlayerState - ) { - onStateChangeCb(state) - } - - override fun onCurrentSecond( - youTubePlayer: YouTubePlayer, - second: Float - ) { - onSecondCb(second) - } - - override fun onVideoDuration( - youTubePlayer: YouTubePlayer, - duration: Float - ) { - onDurationCb(duration) - } - - override fun onError( - youTubePlayer: YouTubePlayer, - error: PlayerConstants.PlayerError - ) { - onErrorCb() - } - }, - iFrameOptions - ) - } - }, - modifier = modifier, - update = { - it.visibility = if (visible) View.VISIBLE else View.INVISIBLE - // Key changed while the WebView is alive (e.g. trailer metadata - // resolving mid-modal): start the new video the same way init - // would — no WebView reinit, no iframe JS re-parse, no teardown race. - if (loadedKey != null && loadedKey != youtubeKey) { - loadedKey = youtubeKey - runCatching { - if (autoplayOnReady == true) { - boundPlayer?.loadVideo(youtubeKey, 0f) - } else { - boundPlayer?.cueVideo(youtubeKey, 0f) - } - } - } - }, - onRelease = { playerView -> - disposed = true - // Pause before release so the renderer is not torn down mid-decode; - // narrows the async WebView-teardown window on rapid reopen. - runCatching { boundPlayer?.pause() } - boundPlayer = null - loadedKey = null - runCatching { playerView.release() } - onReleasedCb() - } - ) -} - -/** Shown in place of the player when YouTube refuses to embed the video. */ -@Composable -private fun TrailerEmbedFallback(onOpenExternally: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "This trailer cannot be embedded directly by YouTube.", - style = ArvioSkin.typography.body.copy(color = Color.White.copy(alpha = 0.8f)), - modifier = Modifier.padding(bottom = 16.dp) - ) - var fallbackBtnFocused by remember { mutableStateOf(false) } - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(if (fallbackBtnFocused) ArvioSkin.colors.focusOutline else Color(0xFFCC0000)) - .padding(horizontal = 20.dp, vertical = 10.dp) - .onFocusChanged { fallbackBtnFocused = it.isFocused } - .clickable { onOpenExternally() } - ) { - Text( - text = "Watch on YouTube", - style = ArvioSkin.typography.button.copy( - fontWeight = FontWeight.Bold, - color = if (fallbackBtnFocused) Color.Black else Color.White - ) - ) - } - } -} - -/** - * Clean cinema-grade YouTube trailer modal for TV and Mobile. - * - * Nothing of ours is ever drawn on top of the player - our own controls live - * beside it, which is what YouTube's "no overlays or frames in front of any - * part of an embedded player" rule requires. - * - * Mobile: YouTube's own controls are switched on (controls=1). They bring - * play/pause, a draggable scrubber, captions, quality and YouTube's own - * attribution, so the only thing we add is a close button - below the video in - * portrait, in the black letterbox strip beside it in landscape. - * - * TV: native YouTube controls remain available. Playback shortcuts use our - * own bar, drawn in a permanently reserved strip *below* the video: - * - [DPAD_CENTER] / [ENTER]: Play / Pause toggle - * - [DPAD_LEFT] / [MediaRewind]: Seek backward 10s - * - [DPAD_RIGHT] / [MediaFastForward]: Seek forward 10s - * - [DPAD_DOWN]: reveal the bar and move focus into the strip - * - [DPAD_UP]: enter YouTube's controls (directions traverse, OK activates) - * - [BACK]: Dismiss modal cleanly and restore focus to Details - * The strip also offers access to the embedded controls and the YouTube app. - */ -@Composable -fun YouTubeTrailerModal( - youtubeKey: String, - soundEnabled: Boolean = true, - onPlaybackStateChanged: (PlayerConstants.PlayerState) -> Unit = {}, - onClose: () -> Unit -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val isMobile = LocalDeviceType.current.isTouchDevice() - val isLandscape = - LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - - val playerFocusRequester = remember { FocusRequester() } - val embeddedFocusRequester = remember { FocusRequester() } - val youtubeFocusRequester = remember { FocusRequester() } - val settingsFocusRequester = remember { FocusRequester() } - var settingsFocused by remember { mutableStateOf(false) } - - var isYouTubeFocused by remember { mutableStateOf(false) } - - var activePlayer by remember { mutableStateOf(null) } - var nativePlayerView by remember { mutableStateOf(null) } - // Per-video state resets on key change; the player/view handles above persist - // so a mid-modal key switch cues via cueVideo() without losing D-pad control. - val playbackLifecycle = remember(youtubeKey) { TrailerPlaybackLifecycle() } - var playbackError by remember(youtubeKey) { mutableStateOf(false) } - - var isPlaying by remember { mutableStateOf(false) } - KeepScreenOn(active = isPlaying) - var currentSecond by remember { mutableFloatStateOf(0f) } - var duration by remember { mutableFloatStateOf(0f) } - // New video cued into the persistent player: drop stale progress and - // playback state until the player's own callbacks repopulate. - LaunchedEffect(youtubeKey) { - currentSecond = 0f - duration = 0f - isPlaying = false - } - - // Our own bar starts hidden and is only ever shown on a key press (TV). - var showBar by remember { mutableStateOf(false) } - var lastInteractionTime by remember { mutableLongStateOf(SystemClock.uptimeMillis()) } - var transientIndicator by remember { mutableStateOf(null) } - - // Intercept back key on TV and mobile exclusively - BackHandler(enabled = true) { - onClose() - } - - // Turning the phone sideways goes fullscreen, which is what gets the - // navigation bar off the video. We do it ourselves rather than through - // YouTube's fullscreen button, which stays switched off (fullscreen=0). - val activity = remember(context) { context.findActivity() } - PlayerSystemBarsEffect( - activity = activity, - showBars = false, - enabled = isMobile && isLandscape, - deviceType = LocalDeviceType.current - ) - - // TV drives the player with the D-pad, so the video takes focus on open. - // On mobile the taps go to YouTube's own controls instead. - LaunchedEffect(isMobile) { - if (!isMobile) { - delay(150) - runCatching { playerFocusRequester.requestFocus() } - } - } - - // Auto-hide our bar after 3.5s - kept from the original implementation - LaunchedEffect(showBar, isPlaying, lastInteractionTime, isYouTubeFocused) { - if (showBar && isPlaying && !isYouTubeFocused) { - delay(3500) - showBar = false - } - } - - // Transient indicator auto-clear - LaunchedEffect(transientIndicator) { - if (transientIndicator != null) { - delay(900) - transientIndicator = null - } - } - - fun triggerIndicator(type: IndicatorType, text: String, icon: ImageVector) { - transientIndicator = TransientIndicator(type, text, icon) - } - - fun markInteraction() { - showBar = true - lastInteractionTime = SystemClock.uptimeMillis() - } - - fun focusEmbeddedControls() { - runCatching { embeddedFocusRequester.requestFocus() } - nativePlayerView?.requestFocus(View.FOCUS_DOWN) - } - - fun openExternalYouTube() { - try { - val url = "https://www.youtube.com/watch?v=$youtubeKey" - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) - onClose() - } catch (_: Exception) { - Toast.makeText(context, R.string.trailer_open_failed, Toast.LENGTH_SHORT).show() - } - } - - val barAlpha by animateFloatAsState( - targetValue = if (showBar) 1f else 0f, - label = "trailerBarFade" - ) - - val videoSurface: @Composable (Modifier) -> Unit = { modifier -> - Box( - modifier = modifier - .background(Color.Black) - ) { - if (!playbackError) { - TrailerPlayerSurface( - youtubeKey = youtubeKey, - modifier = Modifier.fillMaxSize().focusRequester(embeddedFocusRequester), - onViewReady = { nativePlayerView = it }, - onReady = { player -> - activePlayer = player - if (soundEnabled) player.unMute() else player.mute() - playbackLifecycle.requestStart() - }, - onStateChange = { state -> - onPlaybackStateChanged(state) - when (state) { - PlayerConstants.PlayerState.PLAYING -> { - playbackLifecycle.onPlaying() - isPlaying = true - } - PlayerConstants.PlayerState.PAUSED -> { - playbackLifecycle.onPaused() - isPlaying = false - if (!isMobile) showBar = true - } - PlayerConstants.PlayerState.ENDED -> { - // Keep the end screen and its links accessible until Back. - playbackLifecycle.onEnded() - isPlaying = false - showBar = true - } - else -> {} - } - }, - onSecond = { currentSecond = it }, - onDuration = { duration = it }, - onError = { playbackError = true }, - onReleased = { activePlayer = null; nativePlayerView = null } - ) - } else { - TrailerEmbedFallback(onOpenExternally = { openExternalYouTube() }) - } - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.94f)) - .zIndex(100f) - .onPreviewKeyEvent { event -> - if (event.key == Key.Back || event.key == Key.Escape) { - if (event.type == KeyEventType.KeyUp) { - onClose() - } - true - } else false - } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - if (isMobile) onClose() - }, - contentAlignment = Alignment.Center - ) { - if (isMobile) { - // Size the player ourselves so it is exactly 16:9 and never - // screen-filling: then the black letterbox strips belong to us and - // a button placed there sits beside the player, not over it. - BoxWithConstraints( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - // One frame for both orientations: side slots in landscape, a - // slot underneath in portrait. The player keeps the same place - // in the layout either way, so turning the phone does not - // dispose it and restart the trailer - the activity handles the - // orientation change itself (configChanges in the manifest). - val sideSlot: Dp = if (isLandscape) mobileCloseSlot else 0.dp - val availableWidth: Dp = - if (isLandscape) maxWidth - sideSlot * 2 else maxWidth * 0.96f - val availableHeight: Dp = - if (isLandscape) maxHeight else maxHeight - mobileCloseSlot - val videoWidth = minOf(availableWidth, availableHeight * (16f / 9f)) - val videoHeight = videoWidth * (9f / 16f) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Row(verticalAlignment = Alignment.CenterVertically) { - // Mirror of the button slot, so the video stays centred - Spacer(modifier = Modifier.width(sideSlot)) - videoSurface( - Modifier - .width(videoWidth) - .height(videoHeight) - ) - Box( - modifier = Modifier.width(sideSlot), - contentAlignment = Alignment.Center - ) { - if (isLandscape) { - TrailerCloseButton(onClose = onClose) - } - } - } - if (!isLandscape) { - Spacer(modifier = Modifier.height(20.dp)) - TrailerCloseButton(onClose = onClose) - } - } - } - } else { - Column( - modifier = Modifier - .fillMaxWidth(0.80f) - .widthIn(max = 1000.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // No ring around the video, at any focus state. A 3dp gap still - // reads as a frame on the picture, and the office decision on - // the TV layout is explicit that the white frame goes. Focus is - // legible without it: the strip below highlights its own items, - // so "nothing highlighted" means the video has the keys. - Box( - modifier = Modifier - .fillMaxWidth() - .focusRequester(playerFocusRequester) - .focusable() - .onPreviewKeyEvent { event -> - // Once the user enters YouTube's controls, do not steal - // navigation or OK from captions, settings and links. - if (nativePlayerView?.hasFocus() == true) { - // Mobile WebView controls use keyboard focus traversal. - // Translate the remote into ordinary keyboard events, - // without inspecting or modifying YouTube's document. - val keyCode = when (event.key) { - Key.DirectionLeft, Key.DirectionRight, - Key.DirectionUp, Key.DirectionDown -> android.view.KeyEvent.KEYCODE_TAB - Key.DirectionCenter -> android.view.KeyEvent.KEYCODE_SPACE - Key.Enter, Key.NumPadEnter -> android.view.KeyEvent.KEYCODE_ENTER - else -> null - } - if (keyCode != null) { - val original = event.nativeKeyEvent - val reverse = event.key == Key.DirectionLeft || event.key == Key.DirectionUp - nativePlayerView?.dispatchKeyEvent(android.view.KeyEvent( - original.downTime, original.eventTime, original.action, - keyCode, original.repeatCount, - if (reverse) android.view.KeyEvent.META_SHIFT_ON else 0 - )) - return@onPreviewKeyEvent true - } - return@onPreviewKeyEvent false - } - if (event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.MediaPlayPause -> { - if (isPlaying) { - activePlayer?.pause() - isPlaying = false - triggerIndicator(IndicatorType.PAUSE, "Paused", Icons.Default.Pause) - } else { - activePlayer?.play() - isPlaying = true - triggerIndicator(IndicatorType.PLAY, "Playing", Icons.Default.PlayArrow) - } - markInteraction() - true - } - Key.MediaPlay -> { - activePlayer?.play() - isPlaying = true - triggerIndicator(IndicatorType.PLAY, "Playing", Icons.Default.PlayArrow) - markInteraction() - true - } - Key.MediaPause -> { - activePlayer?.pause() - isPlaying = false - triggerIndicator(IndicatorType.PAUSE, "Paused", Icons.Default.Pause) - markInteraction() - true - } - Key.DirectionLeft, Key.MediaRewind -> { - val target = (currentSecond - 10f).coerceAtLeast(0f) - activePlayer?.seekTo(target) - currentSecond = target - triggerIndicator(IndicatorType.SEEK_BACK, "-10s", Icons.Default.FastRewind) - markInteraction() - true - } - Key.DirectionRight, Key.MediaFastForward -> { - val target = (currentSecond + 10f).coerceAtMost(duration) - activePlayer?.seekTo(target) - currentSecond = target - triggerIndicator(IndicatorType.SEEK_FORWARD, "+10s", Icons.Default.FastForward) - markInteraction() - true - } - Key.DirectionUp -> { - focusEmbeddedControls() - markInteraction() - true - } - Key.DirectionDown -> { - markInteraction() - runCatching { youtubeFocusRequester.requestFocus() } - true - } - Key.Back, Key.Escape -> { - onClose() - true - } - else -> false - } - } else if (event.type == KeyEventType.KeyUp) { - when (event.key) { - Key.DirectionCenter, Key.Enter, Key.NumPadEnter, - Key.DirectionLeft, Key.DirectionRight, Key.DirectionUp, Key.DirectionDown, - Key.MediaPlayPause, Key.MediaPlay, Key.MediaPause, - Key.MediaFastForward, Key.MediaRewind, - Key.Back, Key.Escape -> true - else -> false - } - } else false - } - ) { - videoSurface( - Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - ) - } - - // The strip below the video: reserved permanently, so the video - // never resizes. Everything of ours lives in here. - Column( - modifier = Modifier - .fillMaxWidth() - .height(tvStripHeight) - .padding(top = 10.dp) - ) { - val progress = if (duration > 0f) { - (currentSecond / duration).coerceIn(0f, 1f) - } else { - 0f - } - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .alpha(barAlpha) - .clip(RoundedCornerShape(2.dp)) - .background(Color.White.copy(alpha = 0.25f)) - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction = progress) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)) - .background(ArvioSkin.colors.focusOutline) - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "${formatTime(currentSecond)} / ${formatTime(duration)}", - style = ArvioSkin.typography.caption.copy( - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - color = Color.White.copy(alpha = 0.9f) - ), - modifier = Modifier.alpha(barAlpha) - ) - - // The +/-10s badge used to sit in the middle of the - // video. It lives down here now. - AnimatedVisibility( - visible = transientIndicator != null, - enter = fadeIn(), - exit = fadeOut() - ) { - transientIndicator?.let { ind -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = ind.icon, - contentDescription = null, - tint = ArvioSkin.colors.focusOutline, - modifier = Modifier.size(16.dp) - ) - Text( - text = ind.text, - style = ArvioSkin.typography.caption.copy( - fontWeight = FontWeight.Bold, - fontSize = 12.sp, - color = Color.White - ) - ) - } - } - } - - YouTubeStripButton( - label = "YouTube · " + stringResource(R.string.settings), - isFocused = settingsFocused, - onFocusChanged = { settingsFocused = it }, - focusRequester = settingsFocusRequester, - onActivate = { focusEmbeddedControls() }, - onUp = { focusEmbeddedControls() }, - onBack = onClose - ) - YouTubeStripButton( - isFocused = isYouTubeFocused, - onFocusChanged = { isYouTubeFocused = it }, - focusRequester = youtubeFocusRequester, - onActivate = { openExternalYouTube() }, - onUp = { runCatching { playerFocusRequester.requestFocus() } }, - onBack = onClose - ) - } - } - } - } - } - - val latestActivePlayer by rememberUpdatedState(activePlayer) - DisposableEffect(lifecycleOwner, youtubeKey) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_PAUSE -> { - playbackLifecycle.onBackground() - latestActivePlayer?.pause() - } - Lifecycle.Event.ON_RESUME -> if (playbackLifecycle.onForeground()) latestActivePlayer?.play() - else -> {} - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - latestActivePlayer?.pause() - } - } -} - -/** The one thing we draw on mobile - and never over the player. */ -@Composable -private fun TrailerCloseButton(onClose: () -> Unit) { - Box( - modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.18f)) - .clickable { onClose() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), - tint = Color.White, - modifier = Modifier.size(22.dp) - ) - } -} - -/** - * Focusable action beside the player, for its settings or the external app. - */ -@Composable -private fun YouTubeStripButton( - label: String = "YouTube", - isFocused: Boolean, - onFocusChanged: (Boolean) -> Unit, - focusRequester: FocusRequester, - onActivate: () -> Unit, - onUp: () -> Unit, - onBack: () -> Unit -) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .background( - if (isFocused) ArvioSkin.colors.focusOutline else Color.White.copy(alpha = 0.12f) - ) - .focusRequester(focusRequester) - .onFocusChanged { onFocusChanged(it.isFocused) } - .focusable() - .onPreviewKeyEvent { event -> - if (event.type == KeyEventType.KeyDown) { - when (event.key) { - Key.DirectionUp -> { - onUp() - true - } - Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { - onActivate() - true - } - Key.Back, Key.Escape -> { - onBack() - true - } - else -> false - } - } else false - } - .clickable { onActivate() } - .padding(horizontal = 10.dp, vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - tint = if (isFocused) Color.Black else Color.White, - modifier = Modifier.size(14.dp) - ) - Text( - text = label, - style = ArvioSkin.typography.caption.copy( - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp, - color = if (isFocused) Color.Black else Color.White - ) - ) - } -} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 59a804fef0..24034b88e1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -183,7 +183,7 @@ import com.arflix.tv.ui.components.SidebarItem import com.arflix.tv.ui.components.SkeletonDetailsPage import com.arflix.tv.ui.components.SkeletonEpisodeCard import com.arflix.tv.ui.components.StreamSelector -import com.arflix.tv.ui.components.YouTubeTrailerModal +import com.arflix.tv.ui.components.TrailerPlayer import androidx.activity.compose.BackHandler import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight @@ -968,15 +968,49 @@ fun DetailsScreen( } ) + // In-app Trailer Player (fullscreen overlay) if (showTrailerPlayer && uiState.trailerKey != null) { - YouTubeTrailerModal( - youtubeKey = uiState.trailerKey!!, - onClose = { - showTrailerPlayer = false - runCatching { focusRequester.requestFocus() } + val closeTrailer = { + showTrailerPlayer = false + runCatching { focusRequester.requestFocus() } + } + BackHandler { closeTrailer() } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .zIndex(50f) + .clickable { closeTrailer() } + ) { + TrailerPlayer( + youtubeKey = uiState.trailerKey!!, + modifier = Modifier.fillMaxSize(), + delayMs = 0L, + volume = 1f + ) + // Close button for touch devices + if (isMobile) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 48.dp, end = 16.dp) + .size(40.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)) + .clickable { closeTrailer() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } } - ) + } } + // Stream Selector Modal // With "only search Telegram when clicking", Telegram is offered as a row the user selects // (see TelegramSearchRow). It is added here, for display only: autoplay, pre-warming and 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 82cda71952..a636755a1d 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 @@ -148,6 +148,7 @@ import com.arflix.tv.ui.components.FeaturedMediaCard import com.arflix.tv.ui.components.movieGenreNameRes import com.arflix.tv.ui.components.tvGenreNameRes import com.arflix.tv.ui.components.MediaCard as ArvioMediaCard +import com.arflix.tv.ui.components.TrailerPlayer import com.arflix.tv.ui.components.CardLayoutMode import com.arflix.tv.ui.components.AppTopBar import com.arflix.tv.ui.components.AppTopBarContentTopInset @@ -157,7 +158,6 @@ import com.arflix.tv.ui.components.SkeletonPosterCard import com.arflix.tv.ui.components.SkeletonMediaCard import androidx.compose.material3.TextButton import com.arflix.tv.ui.components.MobileHeroBanner -import com.arflix.tv.ui.components.HomeTrailerPreview import com.arflix.tv.ui.components.MobileHeroLayoutSpec import com.arflix.tv.ui.components.resolveMobileHeroLayout import com.arflix.tv.ui.components.ProfileAvatarVisual @@ -210,6 +210,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.withContext import dagger.hilt.android.EntryPointAccessors +import com.arflix.tv.ui.components.TrailerPlayerEntryPoint import kotlin.math.max import kotlin.math.min import kotlin.math.abs @@ -1001,9 +1002,6 @@ fun HomeScreen( val focusedCategory = displayCategories.getOrNull(focusState.currentRowIndex) val focusedHomeItem = focusedCategory?.items?.getOrNull(focusState.currentItemIndex) - val focusedTrailerAtMs = remember(focusedCategory?.id, focusedHomeItem?.let { homeRowItemKey(it) }, focusState.isSidebarFocused, showContextMenu) { - SystemClock.elapsedRealtime() - } // Reserve space before metadata arrives, keeping the rail still while a trailer loads. val reserveTrailerSpace = !isMobile && uiState.trailerAutoPlay && uiState.trailerInCards && focusedCategory?.id != "continue_watching" && focusedHomeItem != null && @@ -1186,6 +1184,15 @@ fun HomeScreen( if (viewModel.getIptvStreamUrl(item.id) == null) viewModel.resolveIptvChannels(listOf(item)) } + var isTrailerPlaying by remember { mutableStateOf(false) } + var trailerSuppressed by remember { mutableStateOf(false) } + LaunchedEffect(displayHeroItem?.id) { trailerSuppressed = false } + val heroRowIsContinueWatching = latestDisplayCategories + .getOrNull(focusState.currentRowIndex)?.id == "continue_watching" + // Vertical (poster) rows have no in-card trailer, so the hero plays it for them. + val heroRowIsPoster = latestDisplayCategories + .getOrNull(focusState.currentRowIndex)?.isPortrait(usePosterCards) == true + var heroPlaybackHandles by remember { mutableStateOf(null) } var preparedHeroVideoUrl by remember { mutableStateOf(null) } val heroExoPlayer = heroPlaybackHandles?.player @@ -1352,6 +1359,17 @@ fun HomeScreen( ) } + // YouTube trailer auto-play — on TV, trailer plays inside the focused card instead + if ((isMobile || !uiState.trailerInCards || heroRowIsPoster) && 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 === Box( modifier = Modifier @@ -1460,6 +1478,8 @@ fun HomeScreen( fastScrollThresholdMs = fastScrollThresholdMs, usePosterCards = usePosterCards, isContextMenuOpen = showContextMenu, + trailerIsPlaying = isTrailerPlaying, + onTrailerStop = { trailerSuppressed = true }, isMobile = isMobile, heroItem = displayHeroItem, heroOverviewOverride = displayHeroOverview, @@ -1571,35 +1591,6 @@ fun HomeScreen( } // end trailer-dim wrapper } - // 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 (!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 - // player minimum: rounding to physical pixels can make 200dp - // slightly smaller on 720p TVs (e.g. 266px at 213dpi). - val availableHeight = configuration.screenHeightDp.dp - rowsHeight - AppTopBarContentTopInset - 2.dp - val previewWidth = minOf(380.dp, availableHeight * (16f / 9f), configuration.screenWidthDp.dp * 0.4f) - if (previewWidth * (9f / 16f) >= 200.dp) { - HomeTrailerPreview( - youtubeKey = focusedHomeTrailerKey, - delayMs = uiState.trailerDelaySeconds * 1000L, - autoplayNotBeforeMs = focusedTrailerAtMs + uiState.trailerDelaySeconds * 1000L, - volume = if (uiState.trailerSoundEnabled) 1f else 0f, - modifier = Modifier.align(Alignment.TopEnd) - .padding(top = AppTopBarContentTopInset, end = contentStartPadding) - .width(previewWidth).height(previewWidth * (9f / 16f)) - .testTag("home_hero_trailer") - ) { - // The existing hero backdrop already supplies artwork while - // preparing, or when an embed is unavailable. Keep it intact - // instead of repeating it inside a floating thumbnail. - Box(Modifier.fillMaxSize()) - } - } - } - // Error state - show message when loading failed and no content if (!uiState.isLoading && displayCategories.isEmpty() && uiState.error != null) { Box( @@ -2674,6 +2665,8 @@ internal fun HomeInputLayer( fastScrollThresholdMs: Long, usePosterCards: Boolean, isContextMenuOpen: Boolean, + trailerIsPlaying: Boolean = false, + onTrailerStop: () -> Unit = {}, isMobile: Boolean = false, heroItem: MediaItem? = null, heroOverviewOverride: String? = null, @@ -2788,6 +2781,12 @@ internal fun HomeInputLayer( if (isContextMenuOpen || homeView.findFocus() is android.webkit.WebView) { 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 (event.type == KeyEventType.KeyUp && isArvioDpadNavigationKey(event.key)) { dpadRepeatGate.reset() } 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 363ed443ed..e37fce1c90 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 @@ -384,6 +384,7 @@ class HomeViewModel @Inject constructor( private val apkDownloader: com.arflix.tv.updater.ApkDownloader, private val updatePreferences: com.arflix.tv.updater.UpdatePreferences, private val updateStatusManager: com.arflix.tv.updater.UpdateStatusManager, + private val youTubeExtractor: com.arflix.tv.data.api.InAppYouTubeExtractor, @ApplicationContext private val context: Context ) : ViewModel() { private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { @@ -5010,6 +5011,7 @@ class HomeViewModel @Inject constructor( // for the rest of the app session. Successful keys stay in the LRU. if (cached.key != null || SystemClock.elapsedRealtime() - cached.cachedAt < 60_000L) { _uiState.value = _uiState.value.copy(heroTrailerKey = cached.key) + cached.key?.let(::prefetchTrailerUrl) return } } @@ -5027,6 +5029,7 @@ class HomeViewModel @Inject constructor( mediaRepository.contentLanguage == language && heroTrailerRequestKey == requestKey ) { _uiState.value = current.copy(heroTrailerKey = trailerKey) + trailerKey?.let(::prefetchTrailerUrl) } } } @@ -5066,6 +5069,14 @@ class HomeViewModel @Inject constructor( } } + private fun prefetchTrailerUrl(trailerKey: String) { + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + runCatching { + youTubeExtractor.extractPlaybackSource("https://www.youtube.com/watch?v=$trailerKey") + } + } + } + private fun scheduleHeroDetailsFetch(item: MediaItem, fastScrolling: Boolean) { heroDetailsJob?.cancel() 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 7f33de8acd..bfa32970d2 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 @@ -69,7 +69,6 @@ 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 @@ -79,7 +78,7 @@ 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.TrailerPlayer import com.arflix.tv.ui.components.MediaCard import com.arflix.tv.ui.components.rememberCatalogueRowLayoutMode import com.arflix.tv.ui.theme.ArflixTypography @@ -475,12 +474,7 @@ private fun NetflixBillboard( .build() } } - HomeTrailerPreview( - youtubeKey = trailerKey, - delayMs = 0L, - volume = trailerVolume, - modifier = Modifier.requiredSize(mediaWidth, mediaHeight) - ) { + Box(modifier = Modifier.requiredSize(mediaWidth, mediaHeight)) { if (backdropRequest != null) { AsyncImage( model = backdropRequest, @@ -489,6 +483,13 @@ private fun NetflixBillboard( modifier = Modifier.fillMaxSize() ) } + if (trailerKey != null) { + TrailerPlayer( + youtubeKey = trailerKey, + volume = trailerVolume, + modifier = Modifier.fillMaxSize() + ) + } } if (item != null) { @@ -776,8 +777,6 @@ private fun NetflixContentRow( 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( @@ -789,7 +788,6 @@ private fun NetflixContentRow( trailerKey = trailerKey, trailerDelayMs = trailerDelayMs, trailerVolume = trailerVolume, - onTrailerPlaying = { cardTrailerPlaying = it }, onChannelCardPlaced = onChannelCardPlaced ) isCurrentRow -> MediaCard( @@ -820,8 +818,7 @@ private fun NetflixContentRow( onClick = onClick ) } - val trailerCovering = cardTrailerPlaying && isCurrentRow && index == focusedItemIndex - if (isRanked && index < 10 && !item.isPlaceholder && !trailerCovering) { + if (isRanked && index < 10 && !item.isPlaceholder) { TopRankRibbon( rank = index + 1, isFocused = isCurrentRow && index == focusedItemIndex, @@ -895,7 +892,6 @@ private fun NetflixFeaturedCard( trailerKey: String?, trailerDelayMs: Long, trailerVolume: Float, - onTrailerPlaying: (Boolean) -> Unit = {}, onChannelCardPlaced: (LayoutCoordinates) -> Unit = {} ) { val context = LocalContext.current @@ -927,14 +923,11 @@ private fun NetflixFeaturedCard( .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() - ) { + // Remaining focus delay, fixed when the card opens so recompositions don't restart it. + val startDelayMs = remember(item.id, item.mediaType, trailerKey) { + (focusedAtMs + trailerDelayMs.coerceAtLeast(500L) - SystemClock.elapsedRealtime()).coerceAtLeast(0L) + } + Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) { if (imageRequest != null) { AsyncImage( @@ -944,6 +937,14 @@ private fun NetflixFeaturedCard( modifier = Modifier.fillMaxSize() ) } + if (trailerKey != null) { + TrailerPlayer( + youtubeKey = trailerKey, + delayMs = startDelayMs, + volume = trailerVolume, + modifier = Modifier.fillMaxSize() + ) + } Box( Modifier .fillMaxSize() @@ -1116,10 +1117,3 @@ private fun NetflixChannelPreview( } } -/** 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 2640e25e7d..da218b4fca 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 @@ -5749,6 +5749,14 @@ private fun MobileSettingsSubPage( isFocused = false, onClick = { viewModel.cycleTrailerDelay() } ) + MobileSettingsRow( + icon = Icons.Default.Movie, + title = stringResource(R.string.trailer_in_cards), + value = stringResource(if (uiState.trailerInCards) R.string.on else R.string.off), + toggleChecked = uiState.trailerInCards, + isFocused = false, + onClick = { viewModel.setTrailerInCards(!uiState.trailerInCards) } + ) MobileSettingsRow( icon = Icons.Default.Settings, title = stringResource(R.string.frame_rate), @@ -7595,6 +7603,24 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(12) ) Spacer(modifier = Modifier.height(10.dp)) + SettingsToggleRow( + title = stringResource(R.string.trailer_auto_play), + subtitle = stringResource(R.string.trailer_desc), + isEnabled = trailerAutoPlay, + isFocused = focusedIndex == 13, + onToggle = onTrailerAutoPlayToggle, + modifier = Modifier.settingsFocusSlot(13) + ) + Spacer(modifier = Modifier.height(10.dp)) + SettingsToggleRow( + title = stringResource(R.string.trailer_sound), + subtitle = stringResource(R.string.trailer_sound_desc), + isEnabled = trailerSoundEnabled, + isFocused = focusedIndex == 14, + onToggle = onTrailerSoundEnabledToggle, + modifier = Modifier.settingsFocusSlot(14) + ) + Spacer(modifier = Modifier.height(10.dp)) SettingsRow( icon = Icons.Default.Movie, title = stringResource(R.string.frame_rate), diff --git a/app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt deleted file mode 100644 index d287241d4a..0000000000 --- a/app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.arflix.tv.ui.components - -import org.junit.Assert.* -import org.junit.Test - -class HomeTrailerPreviewTest { - @Test fun fullyVisiblePlayerCanAutoplay() { - assertTrue(canAutoplayHomeTrailer(480f, 270f, 480f * 270f)) - } - @Test fun smallOrMostlyClippedPlayerCannotAutoplay() { - assertFalse(canAutoplayHomeTrailer(355f, 199f, 355f * 199f)) - assertFalse(canAutoplayHomeTrailer(199f, 270f, 199f * 270f)) - assertFalse(canAutoplayHomeTrailer(480f, 270f, 480f * 135f)) - assertFalse(canAutoplayHomeTrailer(480f, 270f, 480f * 134f)) - assertFalse(canAutoplayHomeTrailer(480f, 270f, 0f)) - } - @Test fun documentedMinimumSizeAndVisibilityAreAccepted() { - assertTrue(canAutoplayHomeTrailer(200f, 200f, 200f * 101f)) - } -} diff --git a/app/src/test/kotlin/com/arflix/tv/ui/components/YouTubeTrailerUrlTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/components/YouTubeTrailerUrlTest.kt deleted file mode 100644 index 85ca94e7d0..0000000000 --- a/app/src/test/kotlin/com/arflix/tv/ui/components/YouTubeTrailerUrlTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.arflix.tv.ui.components - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class YouTubeTrailerUrlTest { - @Test fun createsOnlyAnOfficialWatchPageUrl() { - assertEquals("https://www.youtube.com/watch?v=Ab_cd-Ef123", youtubeTrailerUrl("Ab_cd-Ef123")) - } - - @Test fun rejectsMalformedKeysAndUrlInjection() { - listOf("", "abc", "https://example.com", "abcdefghijk&x=1", "abcdefghij/", "abcdefghij<") - .forEach { assertNull(it, youtubeTrailerUrl(it)) } - } -} From b8535eebcb9ee7c1c04050c4f165e61bbb9d7e81 Mon Sep 17 00:00:00 2001 From: silentbil Date: Fri, 16 Oct 2026 17:46:16 +0300 Subject: [PATCH 2/2] feat(trailers): official player for the billboard and Details The in-app player only gets 360p from YouTube, which shows on large surfaces. The Netflix Home billboard and the Details trailer use YouTube's own player (720p-1080p); row cards keep the in-app player. On TV the Details trailer now fills the screen. --- app/build.gradle.kts | 5 + app/proguard-rules.pro | 7 + .../HomeTrailerPreviewDeviceTest.kt | 211 +++++ .../ui/components/YouTubeTrailerDeviceTest.kt | 99 +++ .../tv/ui/components/HomeTrailerPreview.kt | 131 +++ .../tv/ui/components/YouTubeTrailerModal.kt | 778 ++++++++++++++++++ .../tv/ui/screens/details/DetailsScreen.kt | 48 +- .../arflix/tv/ui/screens/home/NetflixHome.kt | 17 +- .../ui/components/HomeTrailerPreviewTest.kt | 20 + 9 files changed, 1267 insertions(+), 49 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt create mode 100644 app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6820647915..1eba274b1c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -503,6 +503,11 @@ ksp { baselineProfile(project(":benchmark")) + // YouTube player for the Home billboard trailer (official IFrame API wrapper) + implementation("com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0") { + exclude(group = "androidx.lifecycle") + } + // NanoHTTPD – lightweight HTTP server for QR-based AI key setup implementation("org.nanohttpd:nanohttpd:2.3.1") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7c3e62ddcd..a2398d4c47 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -259,6 +259,13 @@ -keep class com.arflix.tv.util.UiState { *; } -keep class com.arflix.tv.util.UiState$* { *; } +# ============================================ +# android-youtube-player (IFrame API) +# ============================================ +-keep class com.pierfrancescosoffritti.androidyoutubeplayer.** { *; } +-keepnames class com.pierfrancescosoffritti.youtubeplayer.* { *; } +-dontwarn com.pierfrancescosoffritti.androidyoutubeplayer.** + # sherpa-onnx (audio subtitle sync speech recognition): JNI reads config fields by name. -keep class com.k2fsa.sherpa.onnx.** { *; } -keep interface com.k2fsa.sherpa.onnx.** { *; } diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt new file mode 100644 index 0000000000..d1b8d89596 --- /dev/null +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewDeviceTest.kt @@ -0,0 +1,211 @@ +package com.arflix.tv.ui.components + +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.arflix.tv.data.model.Category +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.ui.screens.home.HomeFocusState +import com.arflix.tv.ui.screens.home.HomeInputLayer +import com.arflix.tv.util.DeviceType +import com.arflix.tv.util.LocalDeviceType +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants.PlayerState +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView +import java.io.File +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.* +import org.junit.Test + +class HomeTrailerPreviewDeviceTest { + @Test fun playerPreparesBeforeTheFocusDeadlineWithoutStartingEarly() { + val cuedAt = java.util.concurrent.atomic.AtomicLong(0L) + val playedAt = java.util.concurrent.atomic.AtomicLong(0L) + val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + val deadline = android.os.SystemClock.elapsedRealtime() + 15_000L + scenario.onActivity { activity -> activity.setContent { + HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), autoplayNotBeforeMs = deadline, + onStateChange = { + if (it == PlayerState.VIDEO_CUED) cuedAt.compareAndSet(0L, android.os.SystemClock.elapsedRealtime()) + if (it == PlayerState.PLAYING) playedAt.compareAndSet(0L, android.os.SystemClock.elapsedRealtime()) + }) { Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) } + } } + await("Player did not prepare during the focus delay", 12_000) { cuedAt.get() > 0L } + assertTrue("Preparation waited for the autoplay deadline", cuedAt.get() < deadline) + assertEquals("Prepared trailer played too early", 0L, playedAt.get()) + var preparedView: YouTubePlayerView? = null + scenario.onActivity { + preparedView = findPlayer(it.window.decorView) + assertEquals(View.INVISIBLE, preparedView!!.visibility) + } + await("Prepared trailer did not autoplay", 40_000) { playedAt.get() > 0L } + assertTrue("Autoplay ignored the focus deadline", playedAt.get() >= deadline) + scenario.onActivity { + assertSame("Autoplay recreated the prepared player", preparedView, findPlayer(it.window.decorView)) + assertEquals(View.VISIBLE, preparedView!!.visibility) + assertEquals(1, webViews(it.window.decorView)) + } + } + } + + @Test fun endedTrailerRestoresArtworkWithoutLooping() { + val state = AtomicReference() + val ended = java.util.concurrent.atomic.AtomicBoolean(false) + val videoDuration = AtomicReference(0f) + val player = AtomicReference() + val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + scenario.onActivity { activity -> activity.setContent { + HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), onStateChange = { + state.set(it) + if (it == PlayerState.ENDED) ended.set(true) + }) { Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) } + } } + await("Preview did not mount") { count(scenario) == 1 } + scenario.onActivity { findPlayer(it.window.decorView)!!.addYouTubePlayerListener(object : AbstractYouTubePlayerListener() { + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + player.set(youTubePlayer) + videoDuration.set(duration) + } + }) } + await("Trailer did not start with its duration", 60_000) { state.get() == PlayerState.PLAYING && videoDuration.get() > 0f } + scenario.onActivity { player.get().seekTo(videoDuration.get() - 0.1f) } + await("Ended trailer did not return to artwork", 20_000) { ended.get() && count(scenario) == 0 } + Thread.sleep(1500) + assertEquals("Ended preview restarted itself", 0, count(scenario)) + } + } + + @Test fun playingCardKeepsRemoteNavigationAndSinglePressSelection() { + val focus = HomeFocusState().apply { userHasNavigated = true } + val opened = AtomicInteger(-1) + val state = AtomicReference() + val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + scenario.onActivity { activity -> activity.setContent { + CompositionLocalProvider(LocalDeviceType provides DeviceType.TV) { + HomeInputLayer(categories = listOf(Category("trending_movies", "Movies", (1..10).map { MediaItem(it, "Title $it") })), + cardLogoUrls = emptyMap(), focusState = focus, limitRowsDuringStartup = false, + suppressSelectUntilMs = 0L, contentStartPadding = 24.dp, fastScrollThresholdMs = 80L, + usePosterCards = false, isContextMenuOpen = false, currentProfile = null, + onNavigateToDetails = { _, id, _, _ -> opened.set(id) }, onNavigateToCollection = {}, + onNavigateToSearch = {}, onNavigateToWatchlist = {}, onNavigateToTv = { _, _ -> }, + onNavigateToSettings = {}, onSwitchProfile = {}, onExitApp = {}, onOpenContextMenu = { _, _ -> }, + featuredTrailerKey = video, featuredTrailerDelayMs = 500L) + } + } } + val first = observePlayer(scenario, state) + await("Focused card did not play", 60_000) { state.get() == PlayerState.PLAYING } + scenario.onActivity { assertEquals(View.VISIBLE, first.visibility); assertEquals(1, webViews(it.window.decorView)) } + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.pressDPadRight() + await("Playing trailer stole D-pad navigation") { focus.currentItemIndex == 1 } + state.set(null) + val next = observePlayer(scenario, state, first) + await("Next focused card did not play", 60_000) { state.get() == PlayerState.PLAYING } + scenario.onActivity { assertEquals(View.VISIBLE, next.visibility); assertEquals(1, webViews(it.window.decorView)) } + device.pressDPadCenter() + await("Select needed more than one press") { opened.get() == 2 } + } + } + + @Test fun rendererFailureFallsBackWithoutClosingTheHost() { + val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" + val state = AtomicReference() + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + scenario.onActivity { activity -> activity.setContent { + HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), onStateChange = state::set) { + Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) + } + } } + // Start playback first: a startup timeout must not satisfy recovery. + await("Preview did not start before the crash test", 60_000) { state.get() == PlayerState.PLAYING } + scenario.onActivity { findWebView(it.window.decorView)!!.loadUrl("chrome://crash") } + // Android may finish its crash dump before delivering the callback. + await("Dead renderer was retained", 60_000) { count(scenario) == 0 } + scenario.onActivity { assertFalse(it.isFinishing); assertFalse(it.isDestroyed) } + } + } + + @Test fun inlineAutoplayReleasesOnFocusLossAndBackground() { + val state = AtomicReference() + val enabled = mutableStateOf(true) + val sawCue = java.util.concurrent.atomic.AtomicBoolean(false) + val video = InstrumentationRegistry.getArguments().getString("trailerVideo") ?: "M7lc1UVf-VE" + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + lateinit var host: ComponentActivity + scenario.onActivity { activity -> + host = activity + activity.setContent { + HomeTrailerPreview(video, Modifier.size(360.dp, 202.5.dp), delayMs = 500, + enabled = enabled.value, onStateChange = { android.util.Log.i("HomeTrailerDevice", "Inline state: $it"); if (it == PlayerState.VIDEO_CUED) sawCue.set(true); state.set(it) }) { + Box(Modifier.size(360.dp, 202.5.dp).background(Color.DarkGray)) + } + } + } + await("Inline trailer did not play", 60_000) { state.get() == PlayerState.PLAYING } + assertTrue("Playback started without first preparing its thumbnail", sawCue.get()) + scenario.onActivity { assertEquals(1, webViews(it.window.decorView)); assertEquals(View.VISIBLE, findPlayer(it.window.decorView)!!.visibility) } + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val output = File(host.getExternalFilesDir(null), "trailer-test").apply { mkdirs() } + device.takeScreenshot(File(output, "home-inline-playing.png")) + scenario.moveToState(Lifecycle.State.CREATED) + await("Background preview retained its WebView") { count(scenario) == 0 } + state.set(null) + scenario.moveToState(Lifecycle.State.RESUMED) + await("Returning did not restore the visible preview", 60_000) { state.get() == PlayerState.PLAYING } + scenario.onActivity { enabled.value = false } + await("Focus loss retained a playing WebView") { count(scenario) == 0 } + scenario.onActivity { assertFalse(it.isFinishing) } + device.takeScreenshot(File(output, "home-inline-released.png")) + } + } + + private fun count(scenario: ActivityScenario): Int { + var result = -1 + scenario.onActivity { result = webViews(it.window.decorView) } + return result + } + private fun webViews(view: View): Int = if (view is WebView) 1 else + if (view is ViewGroup) (0 until view.childCount).sumOf { webViews(view.getChildAt(it)) } else 0 + private fun findWebView(view: View): WebView? = if (view is WebView) view else + if (view is ViewGroup) (0 until view.childCount).firstNotNullOfOrNull { findWebView(view.getChildAt(it)) } else null + private fun findPlayer(view: View): YouTubePlayerView? = if (view is YouTubePlayerView) view else + if (view is ViewGroup) (0 until view.childCount).firstNotNullOfOrNull { findPlayer(view.getChildAt(it)) } else null + private fun observePlayer(scenario: ActivityScenario, playbackState: AtomicReference, previous: YouTubePlayerView? = null): YouTubePlayerView { + var result: YouTubePlayerView? = null + await("Focused preview was not mounted") { + scenario.onActivity { result = findPlayer(it.window.decorView) } + result != null && result !== previous + } + scenario.onActivity { + result!!.addYouTubePlayerListener(object : AbstractYouTubePlayerListener() { + override fun onStateChange(youTubePlayer: YouTubePlayer, state: PlayerState) { playbackState.set(state) } + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { if (second > 0f) playbackState.set(PlayerState.PLAYING) } + }) + } + return result!! + } + private fun await(message: String, timeoutMs: Long = 10_000, predicate: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!predicate() && System.currentTimeMillis() < deadline) Thread.sleep(100) + assertTrue(message, predicate()) + } +} diff --git a/app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt b/app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt new file mode 100644 index 0000000000..b20326f9f2 --- /dev/null +++ b/app/src/androidTest/kotlin/com/arflix/tv/ui/components/YouTubeTrailerDeviceTest.kt @@ -0,0 +1,99 @@ +package com.arflix.tv.ui.components + +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.arflix.tv.util.DeviceType +import com.arflix.tv.util.LocalDeviceType +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants.PlayerState +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.util.concurrent.atomic.AtomicReference + +/** Live integration test using real frame clocks and YouTube playback events. */ +class YouTubeTrailerDeviceTest { + @Test fun playerLoadsAndBackClosesWithoutLeavingHost() { + val args = InstrumentationRegistry.getArguments() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.wakeUp() + val tv = args.getString("trailerDevice") == "tv" + val state = AtomicReference() + val shown = mutableStateOf(true) + ActivityScenario.launch(ComponentActivity::class.java).use { scenario -> + lateinit var host: ComponentActivity + scenario.onActivity { activity -> + host = activity + activity.setContent { + CompositionLocalProvider(LocalDeviceType provides if (tv) DeviceType.TV else DeviceType.PHONE) { + if (shown.value) YouTubeTrailerModal( + youtubeKey = args.getString("trailerVideo") ?: "M7lc1UVf-VE", + onPlaybackStateChanged = { + Log.i("TrailerDeviceTest", "Playback state: $it") + state.set(it) + }, + onClose = { shown.value = false } + ) + } + } + } + val output = File(host.getExternalFilesDir(null), "trailer-test").apply { mkdirs() } + val label = if (tv) "tv" else "phone" + try { + await("YouTube did not start playing", 60000) { state.get() == PlayerState.PLAYING } + Thread.sleep(2000) + device.takeScreenshot(File(output, "$label.png")) + if (tv) { + device.pressDPadCenter() + await("Remote OK did not pause playback") { state.get() == PlayerState.PAUSED } + scenario.moveToState(Lifecycle.State.CREATED) + scenario.moveToState(Lifecycle.State.RESUMED) + Thread.sleep(2000) + assertTrue("A deliberate pause must survive returning to the app", state.get() == PlayerState.PAUSED) + device.takeScreenshot(File(output, "$label-paused-after-return.png")) + device.pressDPadCenter() + await("Remote OK did not resume playback") { state.get() == PlayerState.PLAYING } + device.pressDPadUp() + Thread.sleep(500) + scenario.onActivity { + assertTrue("YouTube controls must accept remote focus", findWebView(it.window.decorView)?.hasFocus() == true) + } + device.pressDPadRight() + device.takeScreenshot(File(output, "$label-native-controls.png")) + + } + val holdMs = args.getString("trailerHoldMs")?.toLongOrNull() ?: 0L + if (holdMs > 0) Thread.sleep(holdMs.coerceAtMost(180000)) + device.pressBack() + await("Back did not close the trailer") { !shown.value } + scenario.onActivity { assertFalse("Back must preserve the host screen", it.isFinishing) } + } finally { + device.takeScreenshot(File(output, "$label-final.png")) + } + } + } + + private fun await(message: String, timeoutMs: Long = 10000, predicate: () -> Boolean) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!predicate() && System.currentTimeMillis() < deadline) Thread.sleep(100) + assertTrue(message, predicate()) + } + + private fun findWebView(view: View): WebView? { + if (view is WebView) return view + if (view is ViewGroup) for (i in 0 until view.childCount) { + findWebView(view.getChildAt(i))?.let { return it } + } + return null + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt new file mode 100644 index 0000000000..653359ecff --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/HomeTrailerPreview.kt @@ -0,0 +1,131 @@ +package com.arflix.tv.ui.components + +import android.graphics.Rect +import android.os.SystemClock +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import kotlinx.coroutines.delay + +/** Documented YouTube autoplay constraints, in CSS pixels (Android dp). */ +internal fun canAutoplayHomeTrailer(width: Float, height: Float, visibleArea: Float): Boolean = + width >= 200f && height >= 200f && visibleArea > width * height * 0.5f + +/** + * One unobscured official embed in the active Home preview slot. Moving focus + * removes the slot; backgrounding/scrolling it off screen releases the WebView. + * Artwork is a replacement, never a layer drawn in front of the player. + */ +@Composable +internal fun HomeTrailerPreview( + youtubeKey: String?, + modifier: Modifier = Modifier, + delayMs: Long = 0L, + autoplayNotBeforeMs: Long? = null, + volume: Float = 0f, + enabled: Boolean = true, + onStateChange: (PlayerConstants.PlayerState) -> Unit = {}, + fallback: @Composable () -> Unit +) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val view = LocalView.current + val density = LocalDensity.current.density + var foreground by remember { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) } + var visible by remember { mutableStateOf(false) } + var settled by remember(youtubeKey, enabled) { mutableStateOf(false) } + var finished by remember(youtubeKey) { mutableStateOf(false) } + var started by remember(youtubeKey) { mutableStateOf(false) } + val latestStateCallback by rememberUpdatedState(onStateChange) + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, _ -> + foreground = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + LaunchedEffect(youtubeKey, enabled, foreground, visible) { + settled = false + if (!youtubeKey.isNullOrBlank() && enabled && foreground && visible) { + // Stabilize the slot, then cue without playing while the focus + // delay is still running. Network preparation need not wait for it. + delay(150L) + settled = true + } + } + var playbackAllowed by remember(youtubeKey, enabled) { mutableStateOf(false) } + LaunchedEffect(youtubeKey, enabled, foreground, visible, delayMs, autoplayNotBeforeMs) { + playbackAllowed = false + if (!youtubeKey.isNullOrBlank() && enabled && foreground && visible) { + val remainingDelay = autoplayNotBeforeMs?.let { it - SystemClock.elapsedRealtime() } ?: delayMs + delay(remainingDelay.coerceAtLeast(0L)) + playbackAllowed = true + } + } + val preparePlayer = settled && enabled && foreground && visible && !finished && !youtubeKey.isNullOrBlank() + // Cue only while preparing. Artwork stays visible until YouTube has its + // own thumbnail ready; playback starts after the unobscured embed is shown. + var cued by remember(youtubeKey, preparePlayer) { mutableStateOf(false) } + var player by remember(youtubeKey, preparePlayer) { mutableStateOf(null) } + val revealPlayer = preparePlayer && cued && playbackAllowed + LaunchedEffect(revealPlayer, player) { + if (revealPlayer) { + withFrameNanos { } + player?.play() + } + } + LaunchedEffect(preparePlayer, playbackAllowed, youtubeKey) { + if (preparePlayer && playbackAllowed) { + started = false + // An unavailable/blocked embed must not leave an empty preview forever. + delay(20_000L) + if (!started) finished = true + } + } + Box(modifier.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + val window = Rect().also { view.getWindowVisibleDisplayFrame(it) } + val intersectionWidth = (minOf(bounds.right, window.right.toFloat()) - maxOf(bounds.left, window.left.toFloat())).coerceAtLeast(0f) + val intersectionHeight = (minOf(bounds.bottom, window.bottom.toFloat()) - maxOf(bounds.top, window.top.toFloat())).coerceAtLeast(0f) + visible = canAutoplayHomeTrailer( + coordinates.size.width / density, coordinates.size.height / density, + intersectionWidth * intersectionHeight / (density * density) + ) + }) { + if (!revealPlayer) fallback() + if (preparePlayer) { + key(youtubeKey) { + TrailerPlayerSurface( + youtubeKey = youtubeKey!!, + modifier = Modifier.fillMaxSize(), + showControls = false, + visible = revealPlayer, + onReady = { readyPlayer -> + player = readyPlayer + if (volume <= 0f) readyPlayer.mute() else { + readyPlayer.unMute() + readyPlayer.setVolume((volume * 100).toInt().coerceIn(0, 100)) + } + false + }, + onStateChange = { state -> + if (state == PlayerConstants.PlayerState.VIDEO_CUED) cued = true + if (state == PlayerConstants.PlayerState.PLAYING) started = true + if (state == PlayerConstants.PlayerState.ENDED) finished = true + latestStateCallback(state) + }, + onError = { finished = true } + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt new file mode 100644 index 0000000000..ce51fb8b85 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt @@ -0,0 +1,778 @@ +package com.arflix.tv.ui.components + +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.SystemClock +import android.view.ViewGroup +import android.view.View +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.arflix.tv.R +import com.arflix.tv.ui.screens.player.common.PlayerSystemBarsEffect +import com.arflix.tv.ui.skin.ArvioSkin +import com.arflix.tv.util.LocalDeviceType +import com.arflix.tv.util.findActivity +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView +import kotlinx.coroutines.delay + +/** + * Height of the TV control strip below the video. + * + * The strip is reserved permanently, even while it looks empty: the modal + * background is black, so an empty strip is invisible, and the video never has + * to change size when our bar appears or hides. + */ +private val tvStripHeight = 64.dp + +/** Space reserved for our close button on mobile. Never sits over the player. */ +private val mobileCloseSlot = 72.dp + +private fun formatTime(seconds: Float): String { + val totalSec = seconds.toInt().coerceAtLeast(0) + val minutes = totalSec / 60 + val remSec = totalSec % 60 + return "%02d:%02d".format(minutes, remSec) +} + +private enum class IndicatorType { + PLAY, PAUSE, SEEK_BACK, SEEK_FORWARD +} + +private data class TransientIndicator( + val type: IndicatorType, + val text: String, + val icon: ImageVector, + val timestamp: Long = SystemClock.uptimeMillis() +) + +/** + * The embedded player itself. Nothing is ever drawn on top of this. + * + * YouTube owns its controls on every device. TV shortcuts are handled by a + * separate focus target; entering the WebView leaves its key handling intact. + */ +@Composable +internal fun TrailerPlayerSurface( + youtubeKey: String, + modifier: Modifier = Modifier, + showControls: Boolean = true, + visible: Boolean = true, + onViewReady: (YouTubePlayerView) -> Unit = {}, + onReady: (YouTubePlayer) -> Boolean = { true }, + onStateChange: (PlayerConstants.PlayerState) -> Unit = {}, + onSecond: (Float) -> Unit = {}, + onDuration: (Float) -> Unit = {}, + onError: () -> Unit = {}, + onReleased: () -> Unit = {} +) { + val onReadyCb by rememberUpdatedState(onReady) + val onStateChangeCb by rememberUpdatedState(onStateChange) + val onSecondCb by rememberUpdatedState(onSecond) + val onDurationCb by rememberUpdatedState(onDuration) + val onErrorCb by rememberUpdatedState(onError) + val onReleasedCb by rememberUpdatedState(onReleased) + val currentKey by rememberUpdatedState(youtubeKey) + // Issue 3: persistent-instance primitive. The WebView is created once by the + // factory below; video switches while it is alive go through cueVideo() in + // `update` instead of tearing the renderer down and cold-starting a new one. + var boundPlayer by remember { mutableStateOf(null) } + var loadedKey by remember { mutableStateOf(null) } + // Remembers the init-time autoplay decision so a mid-modal key switch + // starts the new playback lifecycle the same way (load vs cue). + var autoplayOnReady by remember { mutableStateOf(null) } + var rendererGone by remember { mutableStateOf(false) } + var disposed by remember { mutableStateOf(false) } + + AndroidView( + factory = { ctx -> + YouTubePlayerView(ctx).apply { + visibility = if (visible) View.VISIBLE else View.INVISIBLE + disposed = false + rendererGone = false + enableAutomaticInitialization = false + installTrailerWebViewRecovery(this) { + rendererGone = true + boundPlayer = null + loadedKey = null + if (!disposed) onErrorCb() + } + + // Switches off the embedding library's own overlay UI, which + // would itself sit on top of the player. + setCustomPlayerUi(android.view.View(ctx).apply { visibility = android.view.View.GONE }) + + // Leave the embedded controls, captions and links reachable on TV too. + descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + onViewReady(this) + + val iFrameOptions = IFramePlayerOptions.Builder(ctx) + .controls(if (showControls) 1 else 0) + .rel(0) + .ivLoadPolicy(3) + .ccLoadPolicy(0) + .fullscreen(0) + .build() + + initialize( + object : AbstractYouTubePlayerListener() { + override fun onReady(youTubePlayer: YouTubePlayer) { + if (disposed || rendererGone) return + boundPlayer = youTubePlayer + loadedKey = currentKey + val autoplay = onReadyCb(youTubePlayer) + autoplayOnReady = autoplay + if (autoplay) { + youTubePlayer.loadVideo(currentKey, 0f) + } else { + youTubePlayer.cueVideo(currentKey, 0f) + } + } + + override fun onStateChange( + youTubePlayer: YouTubePlayer, + state: PlayerConstants.PlayerState + ) { + onStateChangeCb(state) + } + + override fun onCurrentSecond( + youTubePlayer: YouTubePlayer, + second: Float + ) { + onSecondCb(second) + } + + override fun onVideoDuration( + youTubePlayer: YouTubePlayer, + duration: Float + ) { + onDurationCb(duration) + } + + override fun onError( + youTubePlayer: YouTubePlayer, + error: PlayerConstants.PlayerError + ) { + onErrorCb() + } + }, + iFrameOptions + ) + } + }, + modifier = modifier, + update = { + it.visibility = if (visible) View.VISIBLE else View.INVISIBLE + // Key changed while the WebView is alive (e.g. trailer metadata + // resolving mid-modal): start the new video the same way init + // would — no WebView reinit, no iframe JS re-parse, no teardown race. + if (loadedKey != null && loadedKey != youtubeKey) { + loadedKey = youtubeKey + runCatching { + if (autoplayOnReady == true) { + boundPlayer?.loadVideo(youtubeKey, 0f) + } else { + boundPlayer?.cueVideo(youtubeKey, 0f) + } + } + } + }, + onRelease = { playerView -> + disposed = true + // Pause before release so the renderer is not torn down mid-decode; + // narrows the async WebView-teardown window on rapid reopen. + runCatching { boundPlayer?.pause() } + boundPlayer = null + loadedKey = null + runCatching { playerView.release() } + onReleasedCb() + } + ) +} + +/** Shown in place of the player when YouTube refuses to embed the video. */ +@Composable +private fun TrailerEmbedFallback(onOpenExternally: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "This trailer cannot be embedded directly by YouTube.", + style = ArvioSkin.typography.body.copy(color = Color.White.copy(alpha = 0.8f)), + modifier = Modifier.padding(bottom = 16.dp) + ) + var fallbackBtnFocused by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (fallbackBtnFocused) ArvioSkin.colors.focusOutline else Color(0xFFCC0000)) + .padding(horizontal = 20.dp, vertical = 10.dp) + .onFocusChanged { fallbackBtnFocused = it.isFocused } + .clickable { onOpenExternally() } + ) { + Text( + text = "Watch on YouTube", + style = ArvioSkin.typography.button.copy( + fontWeight = FontWeight.Bold, + color = if (fallbackBtnFocused) Color.Black else Color.White + ) + ) + } + } +} + +/** + * Clean cinema-grade YouTube trailer modal for TV and Mobile. + * + * Nothing of ours is ever drawn on top of the player - our own controls live + * beside it, which is what YouTube's "no overlays or frames in front of any + * part of an embedded player" rule requires. + * + * Mobile: YouTube's own controls are switched on (controls=1). They bring + * play/pause, a draggable scrubber, captions, quality and YouTube's own + * attribution, so the only thing we add is a close button - below the video in + * portrait, in the black letterbox strip beside it in landscape. + * + * TV: native YouTube controls remain available. Playback shortcuts use our + * own bar, drawn in a permanently reserved strip *below* the video: + * - [DPAD_CENTER] / [ENTER]: Play / Pause toggle + * - [DPAD_LEFT] / [MediaRewind]: Seek backward 10s + * - [DPAD_RIGHT] / [MediaFastForward]: Seek forward 10s + * - [DPAD_DOWN]: reveal the bar and move focus into the strip + * - [DPAD_UP]: enter YouTube's controls (directions traverse, OK activates) + * - [BACK]: Dismiss modal cleanly and restore focus to Details + * The strip also offers access to the embedded controls and the YouTube app. + */ +@Composable +fun YouTubeTrailerModal( + youtubeKey: String, + soundEnabled: Boolean = true, + onPlaybackStateChanged: (PlayerConstants.PlayerState) -> Unit = {}, + onClose: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val isMobile = LocalDeviceType.current.isTouchDevice() + val isLandscape = + LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val playerFocusRequester = remember { FocusRequester() } + val embeddedFocusRequester = remember { FocusRequester() } + val youtubeFocusRequester = remember { FocusRequester() } + val settingsFocusRequester = remember { FocusRequester() } + var settingsFocused by remember { mutableStateOf(false) } + + var isYouTubeFocused by remember { mutableStateOf(false) } + + var activePlayer by remember { mutableStateOf(null) } + var nativePlayerView by remember { mutableStateOf(null) } + // Per-video state resets on key change; the player/view handles above persist + // so a mid-modal key switch cues via cueVideo() without losing D-pad control. + val playbackLifecycle = remember(youtubeKey) { TrailerPlaybackLifecycle() } + var playbackError by remember(youtubeKey) { mutableStateOf(false) } + + var isPlaying by remember { mutableStateOf(false) } + KeepScreenOn(active = isPlaying) + var currentSecond by remember { mutableFloatStateOf(0f) } + var duration by remember { mutableFloatStateOf(0f) } + // New video cued into the persistent player: drop stale progress and + // playback state until the player's own callbacks repopulate. + LaunchedEffect(youtubeKey) { + currentSecond = 0f + duration = 0f + isPlaying = false + } + + // Our own bar starts hidden and is only ever shown on a key press (TV). + var showBar by remember { mutableStateOf(false) } + var lastInteractionTime by remember { mutableLongStateOf(SystemClock.uptimeMillis()) } + var transientIndicator by remember { mutableStateOf(null) } + + // Intercept back key on TV and mobile exclusively + BackHandler(enabled = true) { + onClose() + } + + // Turning the phone sideways goes fullscreen, which is what gets the + // navigation bar off the video. We do it ourselves rather than through + // YouTube's fullscreen button, which stays switched off (fullscreen=0). + val activity = remember(context) { context.findActivity() } + PlayerSystemBarsEffect( + activity = activity, + showBars = false, + enabled = isMobile && isLandscape, + deviceType = LocalDeviceType.current + ) + + // TV drives the player with the D-pad, so the video takes focus on open. + // On mobile the taps go to YouTube's own controls instead. + LaunchedEffect(isMobile) { + if (!isMobile) { + delay(150) + runCatching { playerFocusRequester.requestFocus() } + } + } + + // Auto-hide our bar after 3.5s - kept from the original implementation + LaunchedEffect(showBar, isPlaying, lastInteractionTime, isYouTubeFocused) { + if (showBar && isPlaying && !isYouTubeFocused) { + delay(3500) + showBar = false + } + } + + // Transient indicator auto-clear + LaunchedEffect(transientIndicator) { + if (transientIndicator != null) { + delay(900) + transientIndicator = null + } + } + + fun triggerIndicator(type: IndicatorType, text: String, icon: ImageVector) { + transientIndicator = TransientIndicator(type, text, icon) + } + + fun markInteraction() { + showBar = true + lastInteractionTime = SystemClock.uptimeMillis() + } + + fun focusEmbeddedControls() { + runCatching { embeddedFocusRequester.requestFocus() } + nativePlayerView?.requestFocus(View.FOCUS_DOWN) + } + + fun openExternalYouTube() { + try { + val url = "https://www.youtube.com/watch?v=$youtubeKey" + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + onClose() + } catch (_: Exception) { + Toast.makeText(context, R.string.trailer_open_failed, Toast.LENGTH_SHORT).show() + } + } + + val barAlpha by animateFloatAsState( + targetValue = if (showBar) 1f else 0f, + label = "trailerBarFade" + ) + + val videoSurface: @Composable (Modifier) -> Unit = { modifier -> + Box( + modifier = modifier + .background(Color.Black) + ) { + if (!playbackError) { + TrailerPlayerSurface( + youtubeKey = youtubeKey, + modifier = Modifier.fillMaxSize().focusRequester(embeddedFocusRequester), + onViewReady = { nativePlayerView = it }, + onReady = { player -> + activePlayer = player + if (soundEnabled) player.unMute() else player.mute() + playbackLifecycle.requestStart() + }, + onStateChange = { state -> + onPlaybackStateChanged(state) + when (state) { + PlayerConstants.PlayerState.PLAYING -> { + playbackLifecycle.onPlaying() + isPlaying = true + } + PlayerConstants.PlayerState.PAUSED -> { + playbackLifecycle.onPaused() + isPlaying = false + if (!isMobile) showBar = true + } + PlayerConstants.PlayerState.ENDED -> { + // Keep the end screen and its links accessible until Back. + playbackLifecycle.onEnded() + isPlaying = false + showBar = true + } + else -> {} + } + }, + onSecond = { currentSecond = it }, + onDuration = { duration = it }, + onError = { playbackError = true }, + onReleased = { activePlayer = null; nativePlayerView = null } + ) + } else { + TrailerEmbedFallback(onOpenExternally = { openExternalYouTube() }) + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .zIndex(100f) + .onPreviewKeyEvent { event -> + if (event.key == Key.Back || event.key == Key.Escape) { + if (event.type == KeyEventType.KeyUp) { + onClose() + } + true + } else false + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + if (isMobile) onClose() + }, + contentAlignment = Alignment.Center + ) { + if (isMobile) { + // Size the player ourselves so it is exactly 16:9 and never + // screen-filling: then the black letterbox strips belong to us and + // a button placed there sits beside the player, not over it. + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + // One frame for both orientations: side slots in landscape, a + // slot underneath in portrait. The player keeps the same place + // in the layout either way, so turning the phone does not + // dispose it and restart the trailer - the activity handles the + // orientation change itself (configChanges in the manifest). + val sideSlot: Dp = if (isLandscape) mobileCloseSlot else 0.dp + val availableWidth: Dp = + if (isLandscape) maxWidth - sideSlot * 2 else maxWidth * 0.96f + val availableHeight: Dp = + if (isLandscape) maxHeight else maxHeight - mobileCloseSlot + val videoWidth = minOf(availableWidth, availableHeight * (16f / 9f)) + val videoHeight = videoWidth * (9f / 16f) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Mirror of the button slot, so the video stays centred + Spacer(modifier = Modifier.width(sideSlot)) + videoSurface( + Modifier + .width(videoWidth) + .height(videoHeight) + ) + Box( + modifier = Modifier.width(sideSlot), + contentAlignment = Alignment.Center + ) { + if (isLandscape) { + TrailerCloseButton(onClose = onClose) + } + } + } + if (!isLandscape) { + Spacer(modifier = Modifier.height(20.dp)) + TrailerCloseButton(onClose = onClose) + } + } + } + } else { + // TV: the trailer fills the screen. YouTube's own controls stay reachable (Up), + // and Back closes it. + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // No ring around the video, at any focus state. A 3dp gap still + // reads as a frame on the picture, and the office decision on + // the TV layout is explicit that the white frame goes. Focus is + // legible without it: the strip below highlights its own items, + // so "nothing highlighted" means the video has the keys. + Box( + modifier = Modifier + .fillMaxSize() + .focusRequester(playerFocusRequester) + .focusable() + .onPreviewKeyEvent { event -> + // Once the user enters YouTube's controls, do not steal + // navigation or OK from captions, settings and links. + if (nativePlayerView?.hasFocus() == true) { + // Mobile WebView controls use keyboard focus traversal. + // Translate the remote into ordinary keyboard events, + // without inspecting or modifying YouTube's document. + val keyCode = when (event.key) { + Key.DirectionLeft, Key.DirectionRight, + Key.DirectionUp, Key.DirectionDown -> android.view.KeyEvent.KEYCODE_TAB + Key.DirectionCenter -> android.view.KeyEvent.KEYCODE_SPACE + Key.Enter, Key.NumPadEnter -> android.view.KeyEvent.KEYCODE_ENTER + else -> null + } + if (keyCode != null) { + val original = event.nativeKeyEvent + val reverse = event.key == Key.DirectionLeft || event.key == Key.DirectionUp + nativePlayerView?.dispatchKeyEvent(android.view.KeyEvent( + original.downTime, original.eventTime, original.action, + keyCode, original.repeatCount, + if (reverse) android.view.KeyEvent.META_SHIFT_ON else 0 + )) + return@onPreviewKeyEvent true + } + return@onPreviewKeyEvent false + } + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.DirectionCenter, Key.Enter, Key.NumPadEnter, Key.MediaPlayPause -> { + if (isPlaying) { + activePlayer?.pause() + isPlaying = false + triggerIndicator(IndicatorType.PAUSE, "Paused", Icons.Default.Pause) + } else { + activePlayer?.play() + isPlaying = true + triggerIndicator(IndicatorType.PLAY, "Playing", Icons.Default.PlayArrow) + } + markInteraction() + true + } + Key.MediaPlay -> { + activePlayer?.play() + isPlaying = true + triggerIndicator(IndicatorType.PLAY, "Playing", Icons.Default.PlayArrow) + markInteraction() + true + } + Key.MediaPause -> { + activePlayer?.pause() + isPlaying = false + triggerIndicator(IndicatorType.PAUSE, "Paused", Icons.Default.Pause) + markInteraction() + true + } + Key.DirectionLeft, Key.MediaRewind -> { + val target = (currentSecond - 10f).coerceAtLeast(0f) + activePlayer?.seekTo(target) + currentSecond = target + triggerIndicator(IndicatorType.SEEK_BACK, "-10s", Icons.Default.FastRewind) + markInteraction() + true + } + Key.DirectionRight, Key.MediaFastForward -> { + val target = (currentSecond + 10f).coerceAtMost(duration) + activePlayer?.seekTo(target) + currentSecond = target + triggerIndicator(IndicatorType.SEEK_FORWARD, "+10s", Icons.Default.FastForward) + markInteraction() + true + } + Key.DirectionUp -> { + focusEmbeddedControls() + markInteraction() + true + } + Key.DirectionDown -> { + markInteraction() + true + } + Key.Back, Key.Escape -> { + onClose() + true + } + else -> false + } + } else if (event.type == KeyEventType.KeyUp) { + when (event.key) { + Key.DirectionCenter, Key.Enter, Key.NumPadEnter, + Key.DirectionLeft, Key.DirectionRight, Key.DirectionUp, Key.DirectionDown, + Key.MediaPlayPause, Key.MediaPlay, Key.MediaPause, + Key.MediaFastForward, Key.MediaRewind, + Key.Back, Key.Escape -> true + else -> false + } + } else false + } + ) { + videoSurface(Modifier.fillMaxSize()) + } + + } + } + } + + val latestActivePlayer by rememberUpdatedState(activePlayer) + DisposableEffect(lifecycleOwner, youtubeKey) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + playbackLifecycle.onBackground() + latestActivePlayer?.pause() + } + Lifecycle.Event.ON_RESUME -> if (playbackLifecycle.onForeground()) latestActivePlayer?.play() + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + latestActivePlayer?.pause() + } + } +} + +/** The one thing we draw on mobile - and never over the player. */ +@Composable +private fun TrailerCloseButton(onClose: () -> Unit) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.18f)) + .clickable { onClose() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } +} + +/** + * Focusable action beside the player, for its settings or the external app. + */ +@Composable +private fun YouTubeStripButton( + label: String = "YouTube", + isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, + focusRequester: FocusRequester, + onActivate: () -> Unit, + onUp: () -> Unit, + onBack: () -> Unit +) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background( + if (isFocused) ArvioSkin.colors.focusOutline else Color.White.copy(alpha = 0.12f) + ) + .focusRequester(focusRequester) + .onFocusChanged { onFocusChanged(it.isFocused) } + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.DirectionUp -> { + onUp() + true + } + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { + onActivate() + true + } + Key.Back, Key.Escape -> { + onBack() + true + } + else -> false + } + } else false + } + .clickable { onActivate() } + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + tint = if (isFocused) Color.Black else Color.White, + modifier = Modifier.size(14.dp) + ) + Text( + text = label, + style = ArvioSkin.typography.caption.copy( + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + color = if (isFocused) Color.Black else Color.White + ) + ) + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 24034b88e1..59a804fef0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -183,7 +183,7 @@ import com.arflix.tv.ui.components.SidebarItem import com.arflix.tv.ui.components.SkeletonDetailsPage import com.arflix.tv.ui.components.SkeletonEpisodeCard import com.arflix.tv.ui.components.StreamSelector -import com.arflix.tv.ui.components.TrailerPlayer +import com.arflix.tv.ui.components.YouTubeTrailerModal import androidx.activity.compose.BackHandler import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight @@ -968,49 +968,15 @@ fun DetailsScreen( } ) - // In-app Trailer Player (fullscreen overlay) if (showTrailerPlayer && uiState.trailerKey != null) { - val closeTrailer = { - showTrailerPlayer = false - runCatching { focusRequester.requestFocus() } - } - BackHandler { closeTrailer() } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black) - .zIndex(50f) - .clickable { closeTrailer() } - ) { - TrailerPlayer( - youtubeKey = uiState.trailerKey!!, - modifier = Modifier.fillMaxSize(), - delayMs = 0L, - volume = 1f - ) - // Close button for touch devices - if (isMobile) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 48.dp, end = 16.dp) - .size(40.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.6f)) - .clickable { closeTrailer() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), - tint = Color.White, - modifier = Modifier.size(24.dp) - ) - } + YouTubeTrailerModal( + youtubeKey = uiState.trailerKey!!, + onClose = { + showTrailerPlayer = false + runCatching { focusRequester.requestFocus() } } - } + ) } - // Stream Selector Modal // With "only search Telegram when clicking", Telegram is offered as a row the user selects // (see TelegramSearchRow). It is added here, for display only: autoplay, pre-warming and 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 bfa32970d2..21d999787b 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 @@ -78,6 +78,7 @@ 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.TrailerPlayer import com.arflix.tv.ui.components.MediaCard import com.arflix.tv.ui.components.rememberCatalogueRowLayoutMode @@ -474,7 +475,14 @@ private fun NetflixBillboard( .build() } } - Box(modifier = Modifier.requiredSize(mediaWidth, mediaHeight)) { + // The billboard uses YouTube's own player: the in-app player only gets 360p from + // YouTube, which shows at this size. The backdrop stays until the trailer plays. + HomeTrailerPreview( + youtubeKey = trailerKey, + delayMs = 0L, + volume = trailerVolume, + modifier = Modifier.requiredSize(mediaWidth, mediaHeight) + ) { if (backdropRequest != null) { AsyncImage( model = backdropRequest, @@ -483,13 +491,6 @@ private fun NetflixBillboard( modifier = Modifier.fillMaxSize() ) } - if (trailerKey != null) { - TrailerPlayer( - youtubeKey = trailerKey, - volume = trailerVolume, - modifier = Modifier.fillMaxSize() - ) - } } if (item != null) { diff --git a/app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt new file mode 100644 index 0000000000..d287241d4a --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/ui/components/HomeTrailerPreviewTest.kt @@ -0,0 +1,20 @@ +package com.arflix.tv.ui.components + +import org.junit.Assert.* +import org.junit.Test + +class HomeTrailerPreviewTest { + @Test fun fullyVisiblePlayerCanAutoplay() { + assertTrue(canAutoplayHomeTrailer(480f, 270f, 480f * 270f)) + } + @Test fun smallOrMostlyClippedPlayerCannotAutoplay() { + assertFalse(canAutoplayHomeTrailer(355f, 199f, 355f * 199f)) + assertFalse(canAutoplayHomeTrailer(199f, 270f, 199f * 270f)) + assertFalse(canAutoplayHomeTrailer(480f, 270f, 480f * 135f)) + assertFalse(canAutoplayHomeTrailer(480f, 270f, 480f * 134f)) + assertFalse(canAutoplayHomeTrailer(480f, 270f, 0f)) + } + @Test fun documentedMinimumSizeAndVisibilityAreAccepted() { + assertTrue(canAutoplayHomeTrailer(200f, 200f, 200f * 101f)) + } +}