diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 567556c6fb..1eba274b1c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -503,7 +503,7 @@ ksp { baselineProfile(project(":benchmark")) - // YouTube player for trailers (official IFrame API wrapper) + // YouTube player for the Home billboard trailer (official IFrame API wrapper) implementation("com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0") { exclude(group = "androidx.lifecycle") } 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/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 index c63a526235..ce51fb8b85 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/YouTubeTrailerModal.kt @@ -489,7 +489,7 @@ fun YouTubeTrailerModal( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.94f)) + .background(Color.Black) .zIndex(100f) .onPreviewKeyEvent { event -> if (event.key == Key.Back || event.key == Key.Escape) { @@ -553,10 +553,10 @@ fun YouTubeTrailerModal( } } } else { + // TV: the trailer fills the screen. YouTube's own controls stay reachable (Up), + // and Back closes it. Column( - modifier = Modifier - .fillMaxWidth(0.80f) - .widthIn(max = 1000.dp), + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally ) { // No ring around the video, at any focus state. A 3dp gap still @@ -566,7 +566,7 @@ fun YouTubeTrailerModal( // so "nothing highlighted" means the video has the keys. Box( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .focusRequester(playerFocusRequester) .focusable() .onPreviewKeyEvent { event -> @@ -647,7 +647,6 @@ fun YouTubeTrailerModal( } Key.DirectionDown -> { markInteraction() - runCatching { youtubeFocusRequester.requestFocus() } true } Key.Back, Key.Escape -> { @@ -668,110 +667,9 @@ fun YouTubeTrailerModal( } else false } ) { - videoSurface( - Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - ) + videoSurface(Modifier.fillMaxSize()) } - // 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 - ) - } - } } } } 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 826d526d52..2b7e834962 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 @@ -149,6 +149,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 @@ -158,7 +159,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 @@ -212,6 +212,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 @@ -1025,9 +1026,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 && @@ -1215,6 +1213,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 @@ -1381,6 +1388,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 @@ -1489,6 +1507,8 @@ fun HomeScreen( fastScrollThresholdMs = fastScrollThresholdMs, usePosterCards = usePosterCards, isContextMenuOpen = showContextMenu, + trailerIsPlaying = isTrailerPlaying, + onTrailerStop = { trailerSuppressed = true }, isMobile = isMobile, heroItem = displayHeroItem, heroOverviewOverride = displayHeroOverview, @@ -1600,35 +1620,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( @@ -2703,6 +2694,8 @@ internal fun HomeInputLayer( fastScrollThresholdMs: Long, usePosterCards: Boolean, isContextMenuOpen: Boolean, + trailerIsPlaying: Boolean = false, + onTrailerStop: () -> Unit = {}, isMobile: Boolean = false, heroItem: MediaItem? = null, heroOverviewOverride: String? = null, @@ -2850,6 +2843,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.key == Key.Back) { if (event.type == KeyEventType.KeyDown) { val now = SystemClock.elapsedRealtime() 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 3411b61ba0..2a9da79a5f 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 @@ -389,6 +389,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 _homeWatchlistItems = MutableStateFlow>(emptyList()) @@ -5070,6 +5071,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 } } @@ -5087,6 +5089,7 @@ class HomeViewModel @Inject constructor( mediaRepository.contentLanguage == language && heroTrailerRequestKey == requestKey ) { _uiState.value = current.copy(heroTrailerKey = trailerKey) + trailerKey?.let(::prefetchTrailerUrl) } } } @@ -5126,6 +5129,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 29eb076411..7b40703d9b 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 @@ -70,7 +70,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 @@ -81,6 +80,7 @@ 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 @@ -481,6 +481,8 @@ private fun NetflixBillboard( .build() } } + // 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, @@ -788,8 +790,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( @@ -801,7 +801,6 @@ private fun NetflixContentRow( trailerKey = trailerKey, trailerDelayMs = trailerDelayMs, trailerVolume = trailerVolume, - onTrailerPlaying = { cardTrailerPlaying = it }, onChannelCardPlaced = onChannelCardPlaced ) isCurrentRow -> MediaCard( @@ -832,8 +831,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, @@ -907,7 +905,6 @@ private fun NetflixFeaturedCard( trailerKey: String?, trailerDelayMs: Long, trailerVolume: Float, - onTrailerPlaying: (Boolean) -> Unit = {}, onChannelCardPlaced: (LayoutCoordinates) -> Unit = {} ) { val context = LocalContext.current @@ -939,14 +936,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( @@ -956,6 +950,14 @@ private fun NetflixFeaturedCard( modifier = Modifier.fillMaxSize() ) } + if (trailerKey != null) { + TrailerPlayer( + youtubeKey = trailerKey, + delayMs = startDelayMs, + volume = trailerVolume, + modifier = Modifier.fillMaxSize() + ) + } Box( Modifier .fillMaxSize() @@ -1128,10 +1130,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 de6c724d4b..4e73079322 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 @@ -5753,6 +5753,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), @@ -7611,6 +7619,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/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)) } - } -}