From 314fa7dab307f2978d222d06d53a7bb5cb8cd353 Mon Sep 17 00:00:00 2001 From: chill pill 244 Date: Fri, 22 May 2026 18:11:09 -0700 Subject: [PATCH 1/3] feat(cast): integrate Google Cast SDK and implement casting functionality in player --- app/build.gradle.kts | 4 + app/proguard-rules.pro | 8 + app/src/main/AndroidManifest.xml | 4 + .../kotlin/com/arflix/tv/cast/CastManager.kt | 171 ++++++++++++++++++ .../com/arflix/tv/cast/CastOptionsProvider.kt | 39 ++++ .../tv/ui/screens/player/PlayerScreen.kt | 162 +++++++++++++++-- app/src/main/res/values/themes.xml | 26 ++- 7 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/cast/CastManager.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/cast/CastOptionsProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49bf4f5d..2df2653a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -268,6 +268,10 @@ dependencies { // DataStore for preferences implementation("androidx.datastore:datastore-preferences:1.0.0") + // Google Cast SDK — mobile-only at runtime (guarded by DeviceType check), harmless on TV + implementation("com.google.android.gms:play-services-cast-framework:21.4.0") + implementation("androidx.mediarouter:mediarouter:1.7.0") + // Google Sign-In / Credential Manager for TV implementation("androidx.credentials:credentials:1.3.0") implementation("androidx.credentials:credentials-play-services-auth:1.3.0") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 785bd1bf..065e24e8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -158,6 +158,14 @@ -keep class io.ktor.** { *; } -dontwarn io.ktor.** +# ============================================ +# Google Cast SDK +# ============================================ +-keep class com.google.android.gms.cast.** { *; } +-keep class com.google.android.gms.cast.framework.** { *; } +-keep class com.arflix.tv.cast.CastOptionsProvider { *; } +-dontwarn com.google.android.gms.cast.** + # ============================================ # Google Sign-In / Credentials # ============================================ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2b85de3d..00a472ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,10 @@ android:name="io.sentry.auto-init" android:value="false" /> + + (CastState.NotConnected) + val castState: StateFlow = _castState.asStateFlow() + + private var castContext: CastContext? = null + private var currentSession: CastSession? = null + + private val sessionListener = object : SessionManagerListener { + override fun onSessionStarting(session: CastSession) { + _castState.value = CastState.Connecting + } + + override fun onSessionStarted(session: CastSession, sessionId: String) { + currentSession = session + _castState.value = CastState.Casting(session.castDevice?.friendlyName ?: "Chromecast") + } + + override fun onSessionStartFailed(session: CastSession, error: Int) { + currentSession = null + _castState.value = CastState.NotConnected + } + + override fun onSessionEnding(session: CastSession) {} + + override fun onSessionEnded(session: CastSession, error: Int) { + currentSession = null + _castState.value = CastState.NotConnected + } + + override fun onSessionResuming(session: CastSession, sessionId: String) { + _castState.value = CastState.Connecting + } + + override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { + currentSession = session + _castState.value = CastState.Casting(session.castDevice?.friendlyName ?: "Chromecast") + } + + override fun onSessionResumeFailed(session: CastSession, error: Int) { + currentSession = null + _castState.value = CastState.NotConnected + } + + override fun onSessionSuspended(session: CastSession, reason: Int) {} + } + + fun initialize(isMobile: Boolean) { + if (!isMobile) { + _castState.value = CastState.NotAvailable + return + } + if (castContext != null) return + val executor = ContextCompat.getMainExecutor(context) + try { + CastContext.getSharedInstance(context, executor) + .addOnSuccessListener(executor) { ctx -> + castContext = ctx + ctx.sessionManager.addSessionManagerListener(sessionListener, CastSession::class.java) + val active = ctx.sessionManager.currentCastSession + if (active != null) { + currentSession = active + _castState.value = CastState.Casting(active.castDevice?.friendlyName ?: "Chromecast") + } + } + .addOnFailureListener(executor) { + _castState.value = CastState.NotAvailable + } + } catch (_: Exception) { + _castState.value = CastState.NotAvailable + } + } + + fun loadMedia(url: String, title: String, imageUrl: String?, mimeType: String, positionMs: Long) { + val client = currentSession?.remoteMediaClient ?: return + val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE).apply { + putString(MediaMetadata.KEY_TITLE, title) + } + val mediaInfo = MediaInfo.Builder(url) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(mimeType) + .setMetadata(metadata) + .build() + val request = MediaLoadRequestData.Builder() + .setMediaInfo(mediaInfo) + .setCurrentTime(positionMs) + .setAutoplay(true) + .build() + client.load(request) + } + + fun play() { + currentSession?.remoteMediaClient?.play() + } + + fun pause() { + currentSession?.remoteMediaClient?.pause() + } + + fun seekTo(positionMs: Long) { + currentSession?.remoteMediaClient?.seek( + MediaSeekOptions.Builder().setPosition(positionMs).build() + ) + } + + fun skipForward(amountMs: Long = 10_000L) { + seekTo(getApproximatePosition() + amountMs) + } + + fun skipBack(amountMs: Long = 10_000L) { + seekTo((getApproximatePosition() - amountMs).coerceAtLeast(0L)) + } + + fun getApproximatePosition(): Long = + currentSession?.remoteMediaClient?.approximateStreamPosition ?: 0L + + fun getApproximateDuration(): Long = + currentSession?.remoteMediaClient?.mediaInfo?.streamDuration ?: 0L + + fun isRemotePlaying(): Boolean = + currentSession?.remoteMediaClient?.isPlaying == true + + fun disconnect() { + castContext?.sessionManager?.endCurrentSession(true) + } + + fun getRouteSelector(): MediaRouteSelector = + MediaRouteSelector.Builder() + .addControlCategory( + CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + ) + .build() +} diff --git a/app/src/main/kotlin/com/arflix/tv/cast/CastOptionsProvider.kt b/app/src/main/kotlin/com/arflix/tv/cast/CastOptionsProvider.kt new file mode 100644 index 00000000..aa3e5c65 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/cast/CastOptionsProvider.kt @@ -0,0 +1,39 @@ +package com.arflix.tv.cast + +import android.content.Context +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionProvider +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.framework.media.CastMediaOptions +import com.google.android.gms.cast.framework.media.MediaIntentReceiver +import com.google.android.gms.cast.framework.media.NotificationOptions + +class CastOptionsProvider : OptionsProvider { + + override fun getCastOptions(context: Context): CastOptions { + val notificationOptions = NotificationOptions.Builder() + .setTargetActivityClassName(com.arflix.tv.MainActivity::class.java.name) + .setActions( + listOf( + MediaIntentReceiver.ACTION_REWIND, + MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK, + MediaIntentReceiver.ACTION_FORWARD, + MediaIntentReceiver.ACTION_STOP_CASTING, + ), + // Indices of actions to show as compact notification buttons (max 3) + intArrayOf(1, 3) + ) + .setSkipStepMs(10_000L) + .build() + val mediaOptions = CastMediaOptions.Builder() + .setNotificationOptions(notificationOptions) + .build() + return CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .setCastMediaOptions(mediaOptions) + .build() + } + + override fun getAdditionalSessionProviders(context: Context): List? = null +} 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 105ae099..b93b04b5 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 @@ -174,10 +174,17 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.drawscope.Stroke import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.res.stringResource import com.arflix.tv.R +import com.arflix.tv.cast.CastManager +import com.arflix.tv.cast.CastManagerEntryPoint +import dagger.hilt.android.EntryPointAccessors +import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.CastConnected +import androidx.mediarouter.app.MediaRouteChooserDialog /** * Netflix-style Player UI for Android TV @@ -209,6 +216,12 @@ fun PlayerScreen( val coroutineScope = rememberCoroutineScope() val deviceType = LocalDeviceType.current val lifecycleOwner = LocalLifecycleOwner.current + val castManager = remember(context) { + EntryPointAccessors.fromApplication(context.applicationContext, CastManagerEntryPoint::class.java).castManager() + } + val castState by castManager.castState.collectAsStateWithLifecycle() + val isCasting = castState is CastManager.CastState.Casting + val castAvailable = castState !is CastManager.CastState.NotAvailable val isConstrainedPlaybackDevice = remember(context, deviceType) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager deviceType == com.arflix.tv.util.DeviceType.TV && @@ -256,6 +269,12 @@ fun PlayerScreen( } } + // Initialize Cast SDK once on mobile entry. No-op on TV (CastState.NotAvailable). + DisposableEffect(deviceType) { + castManager.initialize(isMobile = deviceType.isTouchDevice()) + onDispose { } + } + var isPlaying by remember { mutableStateOf(false) } var isBuffering by remember { mutableStateOf(true) } var hasPlaybackStarted by remember { mutableStateOf(false) } // Track if playback has actually started @@ -971,6 +990,11 @@ fun PlayerScreen( } val queueControlsSeek: (Long) -> Unit = queueSeek@{ deltaMs -> + if (isCasting) { + if (deltaMs > 0) castManager.skipForward(deltaMs) + else castManager.skipBack(-deltaMs) + return@queueSeek + } if (playerReleased) return@queueSeek val basePosition = if (isControlScrubbing) { scrubPreviewPosition @@ -992,6 +1016,11 @@ fun PlayerScreen( } val commitControlsSeekNow: () -> Unit = commitSeek@{ + if (isCasting) { + castManager.seekTo(scrubPreviewPosition) + isControlScrubbing = false + return@commitSeek + } if (playerReleased) return@commitSeek if (isControlScrubbing) { controlsSeekJob?.cancel() @@ -1000,6 +1029,49 @@ fun PlayerScreen( } } + // When cast session starts: pause local ExoPlayer and hand the URL off to Chromecast. + // When cast session ends: resume local ExoPlayer from the last reported cast position. + LaunchedEffect(castState) { + when (castState) { + is CastManager.CastState.Casting -> { + val url = uiState.selectedStreamUrl ?: return@LaunchedEffect + val posMs = if (!playerReleased) exoPlayer.currentPosition else 0L + if (!playerReleased) exoPlayer.pause() + castManager.loadMedia( + url = url, + title = uiState.title, + imageUrl = uiState.backdropUrl, + mimeType = guessCastMimeType(url), + positionMs = posMs + ) + } + is CastManager.CastState.NotConnected -> { + val resumePos = castManager.getApproximatePosition() + if (!playerReleased && resumePos > 0L && !exoPlayer.isPlaying) { + exoPlayer.seekTo(resumePos) + exoPlayer.play() + } + } + else -> Unit + } + } + + // Poll RemoteMediaClient state at 500 ms intervals while casting so the + // progress bar and play/pause icon reflect what the Chromecast is doing. + LaunchedEffect(isCasting) { + if (!isCasting) return@LaunchedEffect + while (true) { + currentPosition = castManager.getApproximatePosition() + val remoteDuration = castManager.getApproximateDuration() + if (remoteDuration > 0L) duration = remoteDuration + progress = if (duration > 0L) { + (currentPosition.toFloat() / duration.toFloat()).coerceIn(0f, 1f) + } else 0f + isPlaying = castManager.isRemotePlaying() + delay(500) + } + } + LaunchedEffect(uiState.preferredAudioLanguage) { if (playerReleased) return@LaunchedEffect val trackSelector = exoPlayer.trackSelector as? androidx.media3.exoplayer.trackselection.DefaultTrackSelector @@ -1330,8 +1402,8 @@ fun PlayerScreen( } // Auto-hide controls and return focus to container - LaunchedEffect(showControls, isPlaying) { - if (showControls && isPlaying && !showSubtitleMenu && !showSourceMenu && !showSubtitleSettings) { + LaunchedEffect(showControls, isPlaying, isCasting) { + if (showControls && isPlaying && !isCasting && !showSubtitleMenu && !showSourceMenu && !showSubtitleSettings) { delay(5000) showControls = false // Return focus to container so it can receive key events @@ -1347,6 +1419,11 @@ fun PlayerScreen( aiRenderersFactory.syncOffsetUs.set(subtitleSyncOffsetMs * 1000L) } + // When cast starts: keep controls permanently visible. + LaunchedEffect(isCasting) { + if (isCasting) showControls = true + } + // Request focus on play button when controls are shown. // hasPlaybackStarted is also a key because the controls are inside // AnimatedVisibility(visible = hasPlaybackStarted && showControls), @@ -1410,9 +1487,10 @@ fun PlayerScreen( } // Update progress periodically - LaunchedEffect(exoPlayer) { + LaunchedEffect(exoPlayer, isCasting) { while (!playerReleasedAtomic.get()) { if (playerReleasedAtomic.get()) break + if (isCasting) { delay(500); continue } currentPosition = runCatching { exoPlayer.currentPosition }.getOrDefault(currentPosition) viewModel.onPlaybackPosition(currentPosition) val rawDuration = exoPlayer.duration @@ -2207,9 +2285,12 @@ fun PlayerScreen( } else false } ) { + if (isCasting) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) + } // Keep PlayerView mounted as soon as we have a stream URL. // A real video surface must exist during startup, otherwise some streams never transition out of buffering. - if (uiState.selectedStreamUrl != null) { + if (uiState.selectedStreamUrl != null && !isCasting) { AndroidView( factory = { ctx -> PlayerView(ctx).apply { @@ -2432,7 +2513,7 @@ fun PlayerScreen( modifier = Modifier.weight(1f, fill = false) ) - // Right side - Ends At + Clock + // Right side - Cast button (mobile) + Ends At + Clock Column(horizontalAlignment = Alignment.End) { val currentTime = remember { mutableStateOf("") } val endsAtTime = remember { mutableStateOf("") } @@ -2447,6 +2528,52 @@ fun PlayerScreen( kotlinx.coroutines.delay(1000) } } + + // Cast button — top-right, mobile/tablet only + if (isTouchDevice && castAvailable) { + val castDeviceName = (castState as? CastManager.CastState.Casting)?.deviceName + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(bottom = if (endsAtTime.value.isNotBlank() || !isTouchDevice) 4.dp else 0.dp) + ) { + if (castDeviceName != null) { + androidx.tv.material3.Text( + text = castDeviceName, + style = ArflixTypography.caption.copy(fontSize = 11.sp), + color = Color.White.copy(alpha = 0.85f), + maxLines = 1 + ) + } + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background( + if (isCasting) Color.White.copy(alpha = 0.2f) + else Color.Transparent + ) + .clickable { + if (isCasting) { + castManager.disconnect() + } else { + val dialog = MediaRouteChooserDialog(context) + dialog.routeSelector = castManager.getRouteSelector() + dialog.show() + } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isCasting) Icons.Default.CastConnected else Icons.Default.Cast, + contentDescription = if (isCasting) "Stop casting" else "Cast to TV", + tint = if (isCasting) playerAccent else Color.White.copy(alpha = 0.85f), + modifier = Modifier.size(22.dp) + ) + } + } + } + if (!isTouchDevice) { Text( currentTime.value, @@ -2636,7 +2763,14 @@ fun PlayerScreen( contentDescription = if (isPlaying) "Pause" else "Play", focusRequester = playButtonFocusRequester, size = bigBtn, iconSize = bigIcon, onFocusChanged = { if (it) focusedButton = 0 }, - onClick = { if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() }, + onClick = { + if (isCasting) { + if (castManager.isRemotePlaying()) castManager.pause() + else castManager.play() + } else { + if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() + } + }, onLeftKey = { if (isTouchDevice) sourceButtonFocusRequester.requestFocus() else rewindButtonFocusRequester.requestFocus() }, onRightKey = { if (isTouchDevice) aspectButtonFocusRequester.requestFocus() else forwardButtonFocusRequester.requestFocus() }, onDownKey = { trackbarFocusRequester.requestFocus() }, @@ -2715,16 +2849,16 @@ fun PlayerScreen( if (!state.isFocused && isControlScrubbing) commitControlsSeekNow() } .focusable() - .pointerInput(duration) { + .pointerInput(duration, isCasting) { detectHorizontalDragGestures( onDragStart = { offset -> if (duration > 0L && trackbarWidthPx > 0) { scrubPreviewPosition = ((offset.x / trackbarWidthPx).coerceIn(0f, 1f) * duration).toLong(); isControlScrubbing = true } }, - onDragEnd = { if (isControlScrubbing && !playerReleased) { exoPlayer.seekTo(scrubPreviewPosition); isControlScrubbing = false } }, - onDragCancel = { if (isControlScrubbing && !playerReleased) { exoPlayer.seekTo(scrubPreviewPosition); isControlScrubbing = false } }, + onDragEnd = { if (isControlScrubbing) { if (isCasting) castManager.seekTo(scrubPreviewPosition) else if (!playerReleased) exoPlayer.seekTo(scrubPreviewPosition); isControlScrubbing = false } }, + onDragCancel = { if (isControlScrubbing) { if (isCasting) castManager.seekTo(scrubPreviewPosition) else if (!playerReleased) exoPlayer.seekTo(scrubPreviewPosition); isControlScrubbing = false } }, onHorizontalDrag = { _, dragAmount -> if (duration > 0L && trackbarWidthPx > 0) { val delta = (dragAmount / trackbarWidthPx * duration).toLong(); scrubPreviewPosition = (scrubPreviewPosition + delta).coerceIn(0L, duration); isControlScrubbing = true } } ) } - .pointerInput(duration) { - detectTapGestures { offset -> if (duration > 0L && trackbarWidthPx > 0 && !playerReleased) { exoPlayer.seekTo(((offset.x / trackbarWidthPx).coerceIn(0f, 1f) * duration).toLong()) } } + .pointerInput(duration, isCasting) { + detectTapGestures { offset -> if (duration > 0L && trackbarWidthPx > 0) { val pos = ((offset.x / trackbarWidthPx).coerceIn(0f, 1f) * duration).toLong(); if (isCasting) castManager.seekTo(pos) else if (!playerReleased) exoPlayer.seekTo(pos) } } } .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown && trackbarFocused) { @@ -4981,3 +5115,9 @@ private fun PlayerSubtitleSettingRow( } } } + +private fun guessCastMimeType(url: String): String = when { + url.contains(".m3u8", ignoreCase = true) -> "application/x-mpegURL" + url.contains(".mpd", ignoreCase = true) -> "application/dash+xml" + else -> "video/mp4" +} diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2c127160..5c9a5ca6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -26,16 +26,36 @@ true false false + + @color/arctic_white + @color/background_dark + @color/accent_white - - From 51a4cf1f4e5228d370afb9c9e4495fbef7cb3e5e Mon Sep 17 00:00:00 2001 From: chill pill 244 Date: Fri, 22 May 2026 21:05:55 -0700 Subject: [PATCH 2/3] fix(cast): fix double-tap seek and resume position after disconnect - pointerInput key changed from Unit to isCasting so the gesture handler restarts when casting state changes, capturing the updated queueControlsSeek lambda that routes double-tap to castManager.skipForward/Back - track lastCastPositionMs during the 500ms poll loop; use it to seek ExoPlayer on disconnect (remoteMediaClient is null by then so getApproximatePosition() always returned 0) Co-Authored-By: Claude Sonnet 4.6 --- .../arflix/tv/ui/screens/player/PlayerScreen.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 b93b04b5..f3adb3ae 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 @@ -1029,6 +1029,10 @@ fun PlayerScreen( } } + // Tracks the last confirmed position from the Chromecast so we can resume + // ExoPlayer from it after disconnecting (remoteMediaClient is null by then). + var lastCastPositionMs by remember { mutableStateOf(0L) } + // When cast session starts: pause local ExoPlayer and hand the URL off to Chromecast. // When cast session ends: resume local ExoPlayer from the last reported cast position. LaunchedEffect(castState) { @@ -1046,11 +1050,13 @@ fun PlayerScreen( ) } is CastManager.CastState.NotConnected -> { - val resumePos = castManager.getApproximatePosition() + // remoteMediaClient is null here — use the position tracked by the poll loop + val resumePos = lastCastPositionMs if (!playerReleased && resumePos > 0L && !exoPlayer.isPlaying) { exoPlayer.seekTo(resumePos) exoPlayer.play() } + lastCastPositionMs = 0L } else -> Unit } @@ -1061,7 +1067,9 @@ fun PlayerScreen( LaunchedEffect(isCasting) { if (!isCasting) return@LaunchedEffect while (true) { - currentPosition = castManager.getApproximatePosition() + val pos = castManager.getApproximatePosition() + if (pos > 0L) lastCastPositionMs = pos + currentPosition = pos val remoteDuration = castManager.getApproximateDuration() if (remoteDuration > 0L) duration = remoteDuration progress = if (duration > 0L) { @@ -1844,7 +1852,9 @@ fun PlayerScreen( .focusable() .then( if (isTouchDevice) { - Modifier.pointerInput(Unit) { + // isCasting is a key so the handler restarts when casting changes, + // picking up the updated queueControlsSeek lambda. + Modifier.pointerInput(isCasting) { detectTapGestures( onTap = { if (uiState.error == null && !showSubtitleMenu && !showSourceMenu) { From 151285cb15e4406497dee8a74aa67a55c2b2ec7d Mon Sep 17 00:00:00 2001 From: chill pill 244 Date: Thu, 28 May 2026 18:59:06 -0700 Subject: [PATCH 3/3] fix(cast): hide cast button for streams requiring custom request headers Chromecast's default receiver fetches stream URLs directly without any custom HTTP headers. Streams that set proxyHeaders.request (e.g. addons requiring Authorization or Referer) would fail silently on the device. Check behaviorHints.proxyHeaders.request and hide the cast button when any per-stream headers are required, keeping it visible for IPTV and debrid streams where auth is embedded in the URL. Co-Authored-By: Claude Sonnet 4.6 --- .../com/arflix/tv/ui/screens/player/PlayerScreen.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 f3adb3ae..8ff5059b 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 @@ -222,6 +222,10 @@ fun PlayerScreen( val castState by castManager.castState.collectAsStateWithLifecycle() val isCasting = castState is CastManager.CastState.Casting val castAvailable = castState !is CastManager.CastState.NotAvailable + // Hide cast button for streams that require custom request headers (Authorization, Referer, etc.) + // since the Chromecast default receiver fetches the URL directly without those headers. + val streamNeedsHeaders = uiState.selectedStream + ?.behaviorHints?.proxyHeaders?.request?.isNotEmpty() == true val isConstrainedPlaybackDevice = remember(context, deviceType) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager deviceType == com.arflix.tv.util.DeviceType.TV && @@ -2539,8 +2543,8 @@ fun PlayerScreen( } } - // Cast button — top-right, mobile/tablet only - if (isTouchDevice && castAvailable) { + // Cast button — mobile/tablet only; hidden when stream requires custom headers + if (isTouchDevice && castAvailable && !streamNeedsHeaders) { val castDeviceName = (castState as? CastManager.CastState.Casting)?.deviceName Row( verticalAlignment = Alignment.CenterVertically,