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 - -