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