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..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
@@ -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,16 @@ 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
+ // 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 &&
@@ -256,6 +273,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 +994,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 +1020,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 +1033,57 @@ 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) {
+ 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 -> {
+ // 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
+ }
+ }
+
+ // 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) {
+ val pos = castManager.getApproximatePosition()
+ if (pos > 0L) lastCastPositionMs = pos
+ currentPosition = pos
+ 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 +1414,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 +1431,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 +1499,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
@@ -1766,7 +1856,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) {
@@ -2207,9 +2299,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 +2527,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 +2542,52 @@ fun PlayerScreen(
kotlinx.coroutines.delay(1000)
}
}
+
+ // 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,
+ 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 +2777,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 +2863,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 +5129,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
-
-