Merge pull request #2842 from davotoula/add-video-casting-feature

Add LAN video casting via Chromecast (Google Play flavour only)
This commit is contained in:
Vitor Pamplona
2026-05-11 11:52:35 -04:00
committed by GitHub
24 changed files with 1119 additions and 17 deletions
+7
View File
@@ -199,10 +199,12 @@ android {
play {
getIsDefault().set(true)
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "true"
}
fdroid {
dimension "channel"
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "false"
}
}
@@ -379,6 +381,11 @@ dependencies {
//PushNotifications(FDroid)
fdroidImplementation libs.unifiedpush
// Google Cast SDK — Chromecast support. Play flavor only because the
// framework hard-depends on Google Play services, which is unavailable
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
playImplementation libs.play.services.cast.framework
// Charts
implementation libs.vico.charts.compose
implementation libs.vico.charts.m3
+1
View File
@@ -69,3 +69,4 @@
-keep class * extends androidx.room.RoomDatabase {
<init>();
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cast.chromecast
import android.content.Context
import com.vitorpamplona.amethyst.service.cast.CastDevice
import com.vitorpamplona.amethyst.service.cast.CastRequest
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* F-Droid stub: the Google Cast SDK requires Google Play services, which are
* not present on the FOSS build. This caster always reports an empty device
* list so the registry simply returns no devices. The cast button is hidden
* on fdroid via `BuildConfig.IS_CASTING_AVAILABLE`, so this stub should never
* be exercised at runtime; it exists to keep the shared-source-set
* `CastRegistry` constructible.
*/
@Suppress("UNUSED_PARAMETER")
class ChromecastCaster(
appContext: Context,
) {
val devices: StateFlow<List<CastDevice>> = MutableStateFlow<List<CastDevice>>(emptyList()).asStateFlow()
val sessionState: StateFlow<CastSessionState> = MutableStateFlow<CastSessionState>(CastSessionState.Idle).asStateFlow()
fun startDiscovery() = Unit
fun stopDiscovery() = Unit
suspend fun cast(
device: CastDevice,
request: CastRequest,
) = Unit
suspend fun stopCasting() = Unit
}
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
@@ -492,6 +493,14 @@ class AppModules(
Nip95CacheFactory.new(appContext)
}
// LAN cast registry — Chromecast only (play flavor real, fdroid no-op
// stub). Discovery starts only when the picker dialog opens; idle by
// default to keep multicast traffic off.
val castRegistry: CastRegistry by lazy {
Log.d("AppModules", "CastRegistry Init")
CastRegistry(appContext)
}
// local video cache with disk + memory
val videoCache: VideoCache by lazy {
Log.d("AppModules", "VideoCache Init")
@@ -60,7 +60,7 @@ class AccountSyncedSettings(
)
val videoPlayer =
AccountVideoPlayerPreferences(
MutableStateFlow(internalSettings.videoPlayer.buttonItems.toImmutableList()),
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
)
fun toInternal(): AccountSyncedSettingsInternal =
@@ -150,7 +150,8 @@ class AccountSyncedSettings(
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
}
val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList()
val newVideoPlayerButtonItems =
mergeWithDefaultVideoPlayerButtons(syncedSettingsInternal.videoPlayer.buttonItems).toImmutableList()
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
}
@@ -75,6 +75,7 @@ enum class VideoPlayerAction {
Share,
Download,
PictureInPicture,
Cast,
}
@Serializable
@@ -94,11 +95,22 @@ val DefaultVideoPlayerButtonItems =
VideoPlayerButtonItem(VideoPlayerAction.Fullscreen, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
)
// Existing accounts have a button-items list serialized before some actions
// existed (e.g. Cast was added later). Append any default items the saved list
// is missing so new actions surface without forcing a settings reset — the
// user's existing order/locations for actions they already have are preserved.
fun mergeWithDefaultVideoPlayerButtons(saved: List<VideoPlayerButtonItem>): List<VideoPlayerButtonItem> {
val knownActions = saved.mapTo(mutableSetOf()) { it.action }
val missing = DefaultVideoPlayerButtonItems.filter { it.action !in knownActions }
return if (missing.isEmpty()) saved else saved + missing
}
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cast
import androidx.compose.runtime.Immutable
@Immutable
data class CastDevice(
val id: String,
val name: String,
)
@Immutable
data class CastRequest(
val url: String,
val mimeType: String? = null,
val title: String? = null,
val artworkUri: String? = null,
)
// Critical for HLS: sending an `.m3u8` URL with `video/mp4` makes the default
// Cast receiver try to demux a playlist as MP4 and crash, wiping the TV's Cast
// service from the network in the process. Strip query/fragment first so
// signed URLs like `…/stream.m3u8?token=…` still match.
fun CastRequest.effectiveMimeType(): String {
mimeType?.takeIf { it.isNotBlank() }?.let { return it }
val path = url.substringBefore('?').substringBefore('#')
return when {
path.endsWith(".m3u8", ignoreCase = true) -> "application/vnd.apple.mpegurl"
path.endsWith(".mpd", ignoreCase = true) -> "application/dash+xml"
path.endsWith(".webm", ignoreCase = true) -> "video/webm"
path.endsWith(".mkv", ignoreCase = true) -> "video/x-matroska"
else -> "video/mp4"
}
}
sealed class CastSessionState {
object Idle : CastSessionState()
data class Connecting(
val device: CastDevice,
) : CastSessionState()
data class Casting(
val device: CastDevice,
val request: CastRequest,
) : CastSessionState()
data class Error(
val device: CastDevice?,
val message: String,
) : CastSessionState()
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cast
import android.content.Context
import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.atomic.AtomicInteger
private const val TAG = "CastRegistry"
/**
* Thin wrapper around the platform-flavor [ChromecastCaster]. Ref-counts
* discovery starts/stops so multiple UI callers (picker dialog open + cast
* tap) can coordinate without tearing the underlying scan down mid-cast.
*
* On fdroid the wrapped [ChromecastCaster] is a no-op stub — the registry
* still constructs and answers cleanly, but discovery returns no devices.
* Casting is hidden entirely on fdroid via `BuildConfig.IS_CASTING_AVAILABLE`.
*/
class CastRegistry(
appContext: Context,
) {
private val caster = ChromecastCaster(appContext)
val devices: StateFlow<List<CastDevice>> = caster.devices
val sessionState: StateFlow<CastSessionState> = caster.sessionState
private val refCount = AtomicInteger(0)
fun startDiscovery() {
val before = refCount.getAndIncrement()
Log.d(TAG) { "startDiscovery refCount $before -> ${before + 1}" }
if (before == 0) caster.startDiscovery()
}
fun stopDiscovery() {
val after = refCount.decrementAndGet()
Log.d(TAG) { "stopDiscovery refCount -> $after" }
if (after <= 0) {
refCount.set(0)
caster.stopDiscovery()
}
}
suspend fun cast(
device: CastDevice,
request: CastRequest,
) {
Log.d(TAG) { "cast device=${device.name} url=${request.url} mime=${request.mimeType}" }
caster.cast(device, request)
}
suspend fun stopCasting() {
Log.d(TAG) { "stopCasting" }
caster.stopCasting()
}
}
@@ -154,6 +154,7 @@ fun RenderVideoPlayer(
RenderCenterButtons(
controllerState = controllerState,
controllerVisible = controllerVisible,
videoUri = mediaItem.src.videoUri,
modifier = Modifier.align(Alignment.Center),
isLiveStream = isLive,
)
@@ -39,6 +39,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
@@ -70,6 +71,9 @@ fun OverflowMenuButtonPreview() {
onShareClick = {},
onSaveClick = {},
onPipClick = {},
onCastClick = {},
castIcon = MaterialSymbols.Cast,
castContentDescription = "",
)
}
}
@@ -86,6 +90,9 @@ fun AnimatedOverflowMenuButton(
onShareClick: () -> Unit,
onSaveClick: () -> Unit,
onPipClick: () -> Unit,
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@@ -103,6 +110,9 @@ fun AnimatedOverflowMenuButton(
onShareClick = onShareClick,
onSaveClick = onSaveClick,
onPipClick = onPipClick,
onCastClick = onCastClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
)
}
}
@@ -117,6 +127,9 @@ fun OverflowMenuButton(
onShareClick: () -> Unit,
onSaveClick: () -> Unit,
onPipClick: () -> Unit,
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
) {
val menuExpanded = remember { mutableStateOf(false) }
@@ -211,6 +224,16 @@ fun OverflowMenuButton(
onPipClick()
}
}
VideoPlayerAction.Cast -> {
M3ActionRow(
icon = castIcon,
text = castContentDescription,
) {
menuExpanded.value = false
onCastClick()
}
}
}
}
}
@@ -81,6 +81,7 @@ fun AnimatedPlayPauseButton(
controllerVisible: State<Boolean>,
modifier: Modifier = Modifier,
isPlaying: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
AnimatedVisibility(
@@ -89,13 +90,14 @@ fun AnimatedPlayPauseButton(
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
PlayPauseButton(isPlaying, onClick)
PlayPauseButton(isPlaying, enabled, onClick)
}
}
@Composable
fun PlayPauseButton(
isPlaying: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) {
@@ -108,6 +110,7 @@ fun PlayPauseButton(
IconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(80.dp),
) {
if (!isPlaying) {
@@ -26,11 +26,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.seekBackward
import com.vitorpamplona.amethyst.service.playback.composable.skipForward
@@ -41,34 +45,56 @@ import kotlinx.coroutines.delay
fun RenderCenterButtons(
controllerState: MediaControllerState,
controllerVisible: MutableState<Boolean>,
videoUri: String,
modifier: Modifier,
isLiveStream: Boolean = false,
) {
val state = rememberPlayPauseButtonState(controllerState.controller)
val castSessionState by Amethyst.instance.castRegistry.sessionState
.collectAsStateWithLifecycle()
val isCastingThisVideo =
(castSessionState as? CastSessionState.Casting)?.request?.url == videoUri
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!isLiveStream) {
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = false) {
AnimatedSkipButton(
controllerVisible = controllerVisible,
isForward = false,
enabled = !isCastingThisVideo,
) {
controllerState.controller.seekBackward()
}
}
AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) {
AnimatedPlayPauseButton(
controllerVisible,
Modifier,
!state.showPlay,
enabled = !isCastingThisVideo,
) {
state.onClick()
}
if (!isLiveStream) {
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) {
AnimatedSkipButton(
controllerVisible = controllerVisible,
isForward = true,
enabled = !isCastingThisVideo,
) {
controllerState.controller.skipForward()
}
}
}
if (!state.showPlay) {
// Auto-hide controls 3s after playback begins — but stay visible while
// casting so the user can see the disabled transport controls and find
// the cast-stop button in the top bar without tapping the screen first.
if (!state.showPlay && !isCastingThisVideo) {
LaunchedEffect(state.showPlay) {
delay(3000)
controllerVisible.value = false
@@ -33,6 +33,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -48,6 +49,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
@@ -55,11 +58,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.VideoButtonLocation
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.service.cast.CastRequest
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -71,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Preview
@Composable
@@ -136,6 +143,42 @@ fun RenderTopButtons(
val overflowQualityOpen = remember { mutableStateOf(false) }
// Pause local playback while this video is casting so audio doesn't
// double up. Has to survive (a) the player instance being recreated when
// the composable scrolls off-screen and back on, and (b) any later
// playWhenReady=true flips from auto-play-on-attach or end-of-loading.
// A one-shot LaunchedEffect would only pause once; a Player.Listener
// re-pauses every time the player flips back to playing.
val castSessionStateForLocal by Amethyst.instance.castRegistry.sessionState
.collectAsStateWithLifecycle()
val isCastingThisVideo =
(castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri
DisposableEffect(player, isCastingThisVideo) {
if (isCastingThisVideo) {
player.pause()
val listener =
object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) player.pause()
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
} else {
onDispose { }
}
}
// Auto-resume local playback only on a genuine cast→no-cast transition
// (not on cold-mount when nothing is casting). `previousCasting` is keyed
// on videoUri so it resets when the player switches to a different note.
val previousCasting = remember(mediaData.videoUri) { mutableStateOf(false) }
LaunchedEffect(isCastingThisVideo, mediaData.videoUri) {
if (previousCasting.value && !isCastingThisVideo) {
player.play()
}
previousCasting.value = isCastingThisVideo
}
RenderTopButtons(
mediaData = mediaData,
hasMultipleQualities = hasMultipleQualities,
@@ -197,6 +240,27 @@ fun RenderTopButtons(
) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val shareDialogVisible = remember { mutableStateOf(false) }
val castDialogVisible = remember { mutableStateOf(false) }
val castSessionState by Amethyst.instance.castRegistry.sessionState
.collectAsStateWithLifecycle()
val isThisVideoCasting =
(castSessionState as? CastSessionState.Casting)?.request?.url == mediaData.videoUri
val castIcon = if (isThisVideoCasting) MaterialSymbols.CastConnected else MaterialSymbols.Cast
val castContentDescription =
stringRes(if (isThisVideoCasting) R.string.cast_stop_casting else R.string.cast_to_device)
val onCastButtonClick =
remember(isThisVideoCasting) {
{
if (isThisVideoCasting) {
Amethyst.instance.applicationIOScope.launch {
Amethyst.instance.castRegistry.stopCasting()
}
} else {
castDialogVisible.value = true
}
Unit
}
}
val saveAction =
rememberSaveMediaAction { context ->
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
@@ -204,12 +268,33 @@ fun RenderTopButtons(
fun isAvailable(action: VideoPlayerAction): Boolean =
when (action) {
VideoPlayerAction.Fullscreen -> onZoomClick != null
VideoPlayerAction.Mute -> true
VideoPlayerAction.Quality -> hasMultipleQualities
VideoPlayerAction.Share -> true
VideoPlayerAction.Download -> !isLive
VideoPlayerAction.PictureInPicture -> pipSupported
VideoPlayerAction.Fullscreen -> {
onZoomClick != null
}
VideoPlayerAction.Mute -> {
true
}
VideoPlayerAction.Quality -> {
hasMultipleQualities
}
VideoPlayerAction.Share -> {
true
}
VideoPlayerAction.Download -> {
!isLive
}
VideoPlayerAction.PictureInPicture -> {
pipSupported
}
VideoPlayerAction.Cast -> {
BuildConfig.IS_CASTING_AVAILABLE && mediaData.videoUri.startsWith("http", ignoreCase = true)
}
}
val canFullscreen = onZoomClick != null
@@ -281,6 +366,15 @@ fun RenderTopButtons(
onClick = onPictureInPictureClick,
)
}
VideoPlayerAction.Cast -> {
AnimatedTopBarIconButton(
controllerVisible = controllerVisible,
symbol = castIcon,
contentDescription = castContentDescription,
onClick = onCastButtonClick,
)
}
}
}
@@ -295,6 +389,22 @@ fun RenderTopButtons(
onShareClick = { shareDialogVisible.value = true },
onSaveClick = saveAction,
onPipClick = onPictureInPictureClick,
onCastClick = onCastButtonClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
)
}
if (castDialogVisible.value) {
CastDevicePickerDialog(
request =
CastRequest(
url = mediaData.videoUri,
mimeType = mediaData.mimeType,
title = mediaData.title,
artworkUri = mediaData.artworkUri,
),
onDismiss = { castDialogVisible.value = false },
)
}
@@ -69,6 +69,7 @@ fun AnimatedSkipButton(
controllerVisible: State<Boolean>,
isForward: Boolean,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
) {
AnimatedVisibility(
@@ -77,18 +78,20 @@ fun AnimatedSkipButton(
enter = FadeIn,
exit = FadeOut,
) {
SkipButton(isForward = isForward, onClick = onClick)
SkipButton(isForward = isForward, enabled = enabled, onClick = onClick)
}
}
@Composable
fun SkipButton(
isForward: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
val icon = if (isForward) MaterialSymbols.Forward10 else MaterialSymbols.Replay10
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) {
Icon(symbol = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(32.dp))
val tint = if (enabled) Color.White else Color.White.copy(alpha = 0.38f)
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
Icon(symbol = icon, contentDescription = label, tint = tint, modifier = Modifier.size(32.dp))
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.cast
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.cast.CastRequest
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
private const val TAG = "CastPicker"
@Composable
fun CastDevicePickerDialog(
request: CastRequest,
onDismiss: () -> Unit,
registry: CastRegistry = Amethyst.instance.castRegistry,
) {
androidx.compose.runtime.DisposableEffect(registry) {
Log.d(TAG) { "open url=${request.url} mime=${request.mimeType}" }
registry.startDiscovery()
onDispose {
Log.d(TAG) { "dispose -> stopDiscovery" }
registry.stopDiscovery()
}
}
val devices by registry.devices.collectAsStateWithLifecycle()
val sessionState by registry.sessionState.collectAsStateWithLifecycle()
// Application-scoped so the cast survives dialog dismissal — the dialog
// dismisses ~10ms after the device tap, which would cancel a composition-
// scoped coroutine before remoteMediaClient.load() could run, leaving the
// receiver showing the default-receiver splash with no media loaded.
val castScope = Amethyst.instance.applicationIOScope
M3ActionDialog(
title = stringRes(R.string.cast_to_device_dialog_title),
onDismiss = onDismiss,
) {
if (devices.isEmpty()) {
Text(
text = stringRes(R.string.cast_searching_for_devices),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
)
} else {
M3ActionSection {
devices.forEach { device ->
M3ActionRow(
icon = MaterialSymbols.Cast,
text = device.name,
) {
Log.d(TAG) { "tap device=${device.name}" }
// Keep discovery alive across the dialog's onDispose so the caster's
// session listener stays registered until the cast attempt finishes.
// Without this the dialog's stopDiscovery (~10ms after tap) tears the
// SessionManagerListener down before onSessionStarted fires.
registry.startDiscovery()
castScope.launch {
try {
registry.cast(device, request)
} finally {
registry.stopDiscovery()
}
}
onDismiss()
}
}
}
}
if (sessionState is CastSessionState.Error) {
Text(
text = (sessionState as CastSessionState.Error).message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
}
}
}
@@ -53,6 +53,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
@@ -128,6 +129,8 @@ fun SettingsScreen(
ShowImagePreviewChoice(sharedPrefs)
ShowVideoPlaybackChoice(sharedPrefs)
AutoplayVideosChoice(sharedPrefs)
if (BuildConfig.FLAVOR == "play") {
}
ShowUrlPreviewChoice(sharedPrefs)
ShowProfilePictureChoice(sharedPrefs)
ImmersiveScrollingChoice(sharedPrefs)
@@ -58,6 +58,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -103,7 +104,13 @@ fun VideoPlayerSettingsScreen(
@Composable
fun VideoPlayerSettingsContent(accountViewModel: AccountViewModel) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
var items by remember(buttonItems) { mutableStateOf(buttonItems.toList()) }
val displayedItems =
if (BuildConfig.IS_CASTING_AVAILABLE) {
buttonItems
} else {
buttonItems.filter { it.action != VideoPlayerAction.Cast }
}
var items by remember(displayedItems) { mutableStateOf(displayedItems.toList()) }
fun save(newItems: List<VideoPlayerButtonItem>) {
items = newItems.toMutableList()
@@ -330,6 +337,7 @@ fun videoPlayerActionName(action: VideoPlayerAction): String =
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share)
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download)
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip)
VideoPlayerAction.Cast -> stringRes(R.string.video_player_settings_action_cast)
}
@Composable
@@ -341,4 +349,5 @@ fun videoPlayerActionDescription(action: VideoPlayerAction): String =
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share_description)
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download_description)
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip_description)
VideoPlayerAction.Cast -> stringRes(R.string.video_player_settings_action_cast_description)
}
+8
View File
@@ -1887,6 +1887,8 @@
<string name="video_player_settings_action_download_description">Download the video to your device (hidden on live streams)</string>
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
<string name="video_player_settings_action_pip_description">Play the video in a floating window (hidden if unsupported)</string>
<string name="video_player_settings_action_cast">Cast to Device</string>
<string name="video_player_settings_action_cast_description">Send the video to a Chromecast or DLNA receiver on your Wi-Fi (hidden for local files)</string>
<string name="profile_image_of_user">Profile Picture of %1$s</string>
<string name="relay_info">Relay %1$s</string>
@@ -2555,6 +2557,12 @@
<string name="playback_actions_dialog_title">Playback</string>
<string name="video_quality_auto">Auto</string>
<!-- LAN cast (Chromecast) feature -->
<string name="cast_to_device">Cast to device</string>
<string name="cast_stop_casting">Stop casting</string>
<string name="cast_to_device_dialog_title">Cast to…</string>
<string name="cast_searching_for_devices">Searching for devices on your Wi-Fi…</string>
<!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS Upload</string>
<string name="share_hls_video_drawer_description">Publish multi-resolution HLS to your media server</string>
+6
View File
@@ -32,6 +32,12 @@
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/app_notification_channel_id" />
<!-- Google Cast SDK options provider. Only declared in the play
flavor because the SDK hard-depends on Google Play services. -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" />
</application>
</manifest>
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cast.chromecast
import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
class AmethystCastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions =
CastOptions
.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.build()
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
}
@@ -0,0 +1,519 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cast.chromecast
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.core.net.toUri
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaStatus
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.images.WebImage
import com.vitorpamplona.amethyst.service.cast.CastDevice
import com.vitorpamplona.amethyst.service.cast.CastRequest
import com.vitorpamplona.amethyst.service.cast.CastSessionState
import com.vitorpamplona.amethyst.service.cast.effectiveMimeType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
private const val TAG = "ChromecastCaster"
private const val SESSION_START_TIMEOUT_MS = 30_000L
private const val STOP_AWAIT_TIMEOUT_MS = 5_000L
/**
* Google Cast (Chromecast) caster.
*
* Only present in the play flavor — the F-Droid flavor ships a no-op stub
* with the same FQN. The class still works at runtime when Google Play
* services are unavailable: discovery returns an empty list and casts fail
* cleanly.
*/
class ChromecastCaster(
private val appContext: Context,
) {
private val devicesFlow = MutableStateFlow<List<CastDevice>>(emptyList())
val devices: StateFlow<List<CastDevice>> = devicesFlow.asStateFlow()
private val sessionFlow = MutableStateFlow<CastSessionState>(CastSessionState.Idle)
val sessionState: StateFlow<CastSessionState> = sessionFlow.asStateFlow()
private val main = Handler(Looper.getMainLooper())
private var mediaRouter: MediaRouter? = null
private var routeSelector: MediaRouteSelector? = null
@Volatile
private var castContext: CastContext? = null
private var registered = false
// Session listener lifetime is independent of discovery: the picker may
// open and close while a cast is still running, so we register the
// listener once when CastContext becomes available and keep it for the
// lifetime of the caster. Without this, stopDiscovery() during cast()'s
// finally block would unregister the listener mid-session and we'd never
// observe onSessionEnded — leaving sessionFlow out of sync with the TV.
@Volatile
private var sessionListenerAttached = false
private val routes = mutableMapOf<String, MediaRouter.RouteInfo>()
private val routerCallback =
object : MediaRouter.Callback() {
override fun onRouteAdded(
router: MediaRouter,
route: MediaRouter.RouteInfo,
) {
Log.d(TAG) { "onRouteAdded id=${route.id} name=${route.name}" }
updateRoutes(router)
}
override fun onRouteRemoved(
router: MediaRouter,
route: MediaRouter.RouteInfo,
) {
Log.d(TAG) { "onRouteRemoved id=${route.id} name=${route.name}" }
updateRoutes(router)
}
override fun onRouteChanged(
router: MediaRouter,
route: MediaRouter.RouteInfo,
) {
Log.d(TAG) { "onRouteChanged id=${route.id} name=${route.name} selected=${route.isSelected}" }
updateRoutes(router)
}
}
private val mediaClientCallback =
object : RemoteMediaClient.Callback() {
override fun onStatusUpdated() {
val client = currentMediaClient ?: return
val status = client.mediaStatus
Log.d(TAG) {
"media.onStatusUpdated playerState=${playerStateName(client.playerState)} " +
"idleReason=${idleReasonName(status?.idleReason ?: -1)} " +
"pos=${client.approximateStreamPosition}/${client.streamDuration}ms"
}
}
override fun onMediaError(mediaError: com.google.android.gms.cast.MediaError) {
Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" }
}
}
@Volatile
private var currentMediaClient: RemoteMediaClient? = null
private fun attachMediaClientCallback(session: CastSession) {
val client = session.remoteMediaClient ?: return
if (currentMediaClient === client) return
currentMediaClient?.unregisterCallback(mediaClientCallback)
currentMediaClient = client
client.registerCallback(mediaClientCallback)
Log.d(TAG) { "media.callback attached" }
}
private fun detachMediaClientCallback() {
currentMediaClient?.let {
it.unregisterCallback(mediaClientCallback)
Log.d(TAG) { "media.callback detached" }
}
currentMediaClient = null
}
private fun playerStateName(state: Int): String =
when (state) {
MediaStatus.PLAYER_STATE_IDLE -> "IDLE"
MediaStatus.PLAYER_STATE_PLAYING -> "PLAYING"
MediaStatus.PLAYER_STATE_PAUSED -> "PAUSED"
MediaStatus.PLAYER_STATE_BUFFERING -> "BUFFERING"
MediaStatus.PLAYER_STATE_LOADING -> "LOADING"
else -> "UNKNOWN($state)"
}
private fun idleReasonName(reason: Int): String =
when (reason) {
MediaStatus.IDLE_REASON_NONE -> "NONE"
MediaStatus.IDLE_REASON_FINISHED -> "FINISHED"
MediaStatus.IDLE_REASON_CANCELED -> "CANCELED"
MediaStatus.IDLE_REASON_INTERRUPTED -> "INTERRUPTED"
MediaStatus.IDLE_REASON_ERROR -> "ERROR"
else -> "—"
}
private val sessionListener =
object : SessionManagerListener<CastSession> {
override fun onSessionStarting(session: CastSession) {
Log.d(TAG) { "session.onStarting" }
}
override fun onSessionStarted(
session: CastSession,
sessionId: String,
) {
Log.d(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" }
attachMediaClientCallback(session)
pendingSessionStart?.complete(true)
pendingSessionStart = null
}
override fun onSessionStartFailed(
session: CastSession,
error: Int,
) {
Log.w(TAG) { "session.onStartFailed error=$error" }
pendingSessionStart?.complete(false)
pendingSessionStart = null
sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)")
}
override fun onSessionEnding(session: CastSession) {
Log.d(TAG) { "session.onEnding" }
}
override fun onSessionEnded(
session: CastSession,
error: Int,
) {
Log.d(TAG) { "session.onEnded error=$error" }
// If a cast() was awaiting a session start, this is also a terminal
// outcome — the session never reached a usable state. Without
// completing here the cast coroutine hangs and the discovery
// ref-count leaks +1 for every failed attempt.
detachMediaClientCallback()
pendingSessionStart?.complete(false)
pendingSessionStart = null
sessionFlow.value = CastSessionState.Idle
}
override fun onSessionResuming(
session: CastSession,
sessionId: String,
) {
Log.d(TAG) { "session.onResuming id=$sessionId" }
}
override fun onSessionResumed(
session: CastSession,
wasSuspended: Boolean,
) {
Log.d(TAG) { "session.onResumed wasSuspended=$wasSuspended" }
// Resume counts as the session being usable — let cast() proceed.
attachMediaClientCallback(session)
pendingSessionStart?.complete(true)
pendingSessionStart = null
}
override fun onSessionResumeFailed(
session: CastSession,
error: Int,
) {
Log.w(TAG) { "session.onResumeFailed error=$error" }
pendingSessionStart?.complete(false)
pendingSessionStart = null
}
override fun onSessionSuspended(
session: CastSession,
reason: Int,
) {
Log.d(TAG) { "session.onSuspended reason=$reason" }
// Suspended sessions can't accept loads — fail any pending start so
// the caller doesn't try to call remoteMediaClient.load() against
// a broken session.
pendingSessionStart?.complete(false)
pendingSessionStart = null
}
}
@Volatile
private var pendingSessionStart: CompletableDeferred<Boolean>? = null
private fun currentDevice(): CastDevice? =
when (val s = sessionFlow.value) {
is CastSessionState.Connecting -> s.device
is CastSessionState.Casting -> s.device
else -> null
}
private fun ensureCastContext(): CastContext? {
castContext?.let { return it }
val gms = GoogleApiAvailability.getInstance()
val status = gms.isGooglePlayServicesAvailable(appContext)
if (status != ConnectionResult.SUCCESS) {
Log.d(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." }
return null
}
return try {
// The blocking overload is the right choice here: we only call it on
// the main thread, OptionsProvider is declared in the play manifest,
// and the SDK caches the singleton after the first call.
@Suppress("DEPRECATION")
CastContext.getSharedInstance(appContext).also {
castContext = it
attachSessionListener(it)
}
} catch (t: Throwable) {
Log.w(TAG, "Failed to initialize CastContext", t)
null
}
}
private fun attachSessionListener(ctx: CastContext) {
if (sessionListenerAttached) return
ctx.sessionManager.addSessionManagerListener(sessionListener, CastSession::class.java)
sessionListenerAttached = true
Log.d(TAG) { "sessionListener attached (caster lifetime)" }
}
fun startDiscovery() {
Log.d(TAG) { "startDiscovery (already registered? $registered)" }
main.post {
if (registered) {
Log.d(TAG) { "startDiscovery: already registered, no-op" }
return@post
}
val ctx = ensureCastContext()
if (ctx == null) {
Log.d(TAG) { "startDiscovery: CastContext unavailable, aborting" }
return@post
}
val router = MediaRouter.getInstance(appContext)
val selector =
MediaRouteSelector
.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build()
router.addCallback(selector, routerCallback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
mediaRouter = router
routeSelector = selector
registered = true
Log.d(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" }
updateRoutes(router)
}
}
fun stopDiscovery() {
Log.d(TAG) { "stopDiscovery (registered=$registered)" }
main.post {
if (!registered) return@post
mediaRouter?.removeCallback(routerCallback)
// Intentionally NOT removing sessionListener: it must stay attached
// for the lifetime of any in-flight cast session, otherwise the
// session-end callbacks fire into the void.
registered = false
routes.clear()
devicesFlow.value = emptyList()
Log.d(TAG) { "stopDiscovery: router callback removed (sessionListener kept)" }
}
}
private fun updateRoutes(router: MediaRouter) {
val selector = routeSelector ?: return
routes.clear()
for (route in router.routes) {
if (route.isDefault || route.isBluetooth) continue
if (!route.matchesSelector(selector)) continue
routes[route.id] = route
}
val list =
routes.values.map { route ->
CastDevice(
id = route.id,
name = route.name,
)
}
Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" }
devicesFlow.value = list
}
suspend fun cast(
device: CastDevice,
request: CastRequest,
) {
Log.d(TAG) { "cast device=${device.name} url=${request.url}" }
val ctx = withContext(Dispatchers.Main) { ensureCastContext() }
if (ctx == null) {
Log.w(TAG, "cast: CastContext unavailable")
sessionFlow.value = CastSessionState.Error(device, "Google Play services unavailable")
return
}
val route =
withContext(Dispatchers.Main) {
routes[device.id] ?: mediaRouter?.routes?.firstOrNull { it.id == device.id }
}
if (route == null) {
Log.w(TAG) { "cast: route ${device.id} not in current set; offline?" }
sessionFlow.value = CastSessionState.Error(device, "Device went offline")
return
}
sessionFlow.value = CastSessionState.Connecting(device)
val started =
withContext(Dispatchers.Main) {
val existing = ctx.sessionManager.currentCastSession
Log.d(TAG) {
"cast: existing session connected=${existing?.isConnected} hasClient=${existing?.remoteMediaClient != null}"
}
val pending = CompletableDeferred<Boolean>()
// If a previous cast() is still awaiting a callback, fail it
// before swapping in our deferred — otherwise the earlier call
// hangs to the 30s timeout.
pendingSessionStart?.complete(false)
pendingSessionStart = pending
try {
Log.d(TAG) { "cast: selectRoute id=${route.id}" }
mediaRouter?.selectRoute(route)
if (existing?.isConnected == true) {
Log.d(TAG) { "cast: reusing already-connected session, completing immediately" }
pending.complete(true)
}
} catch (t: Throwable) {
Log.w(TAG, "cast: selectRoute threw", t)
pending.complete(false)
}
// Defence in depth: if a callback is somehow missed (SDK bug,
// process killed mid-flight, …) don't hang the coroutine forever.
// The session listener already covers every documented terminal
// state, so reaching the timeout means something genuinely went
// wrong on the SDK side.
val outcome = withTimeoutOrNull(SESSION_START_TIMEOUT_MS) { pending.await() }
if (outcome == null) {
Log.w(TAG) { "cast: session start timed out after ${SESSION_START_TIMEOUT_MS}ms" }
pendingSessionStart = null
}
outcome ?: false
}
if (!started) {
Log.w(TAG, "cast: session start refused")
sessionFlow.value = CastSessionState.Error(device, "Cast session refused")
return
}
val ok =
withContext(Dispatchers.Main) {
val session = ctx.sessionManager.currentCastSession
val client = session?.remoteMediaClient
Log.d(TAG) {
"cast: post-start session=${session != null} connected=${session?.isConnected} client=${client != null}"
}
if (client == null) {
Log.w(TAG, "cast: remoteMediaClient is null (session not fully ready); load skipped")
false
} else {
try {
client.load(buildLoadRequest(request))
Log.d(TAG) { "cast: load() submitted (status=${client.playerState})" }
true
} catch (t: Throwable) {
Log.w(TAG, "cast: remoteMediaClient.load failed", t)
false
}
}
}
sessionFlow.value =
if (ok) {
CastSessionState.Casting(device, request)
} else {
CastSessionState.Error(device, "Could not load media on receiver")
}
}
private fun buildLoadRequest(request: CastRequest): MediaLoadRequestData {
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE)
request.title?.let { metadata.putString(MediaMetadata.KEY_TITLE, it) }
request.artworkUri?.let {
runCatching { metadata.addImage(WebImage(it.toUri())) }
}
val info =
MediaInfo
.Builder(request.url)
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setContentType(request.effectiveMimeType())
.setMetadata(metadata)
.build()
return MediaLoadRequestData
.Builder()
.setMediaInfo(info)
.setAutoplay(true)
.build()
}
suspend fun stopCasting() {
Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" }
// Await MEDIA_STOP before endCurrentSession() — racing them on the
// same main-thread tick loses the stop on some receivers (LG webOS).
val client = currentMediaClient
if (client != null) {
val stopAck = CompletableDeferred<Int>()
withContext(Dispatchers.Main) {
try {
client.stop().setResultCallback { result ->
val status = result.status
Log.d(TAG) {
"remoteMediaClient.stop ack code=${status.statusCode} msg=${status.statusMessage}"
}
stopAck.complete(status.statusCode)
}
} catch (t: Throwable) {
Log.w(TAG, "remoteMediaClient.stop threw", t)
stopAck.complete(-1)
}
}
val acked = withTimeoutOrNull(STOP_AWAIT_TIMEOUT_MS) { stopAck.await() }
if (acked == null) {
Log.w(TAG) { "remoteMediaClient.stop did not ack within ${STOP_AWAIT_TIMEOUT_MS}ms" }
}
}
withContext(Dispatchers.Main) {
try {
// false: receiver app already halted media via stop() above.
// true previously triggered an extra teardown that compounded
// the race — keep the receiver running on its splash screen
// so the next cast can reuse the connection cleanly.
castContext?.sessionManager?.endCurrentSession(false)
} catch (t: Throwable) {
Log.w(TAG, "endCurrentSession failed", t)
}
mediaRouter?.unselect(MediaRouter.UNSELECT_REASON_STOPPED)
}
sessionFlow.value = CastSessionState.Idle
}
}
@@ -55,6 +55,8 @@ object MaterialSymbols {
val CameraFront = MaterialSymbol("\uF2C9")
val CameraRear = MaterialSymbol("\uF2C8")
val Cancel = MaterialSymbol("\uE888")
val Cast = MaterialSymbol("\uE307")
val CastConnected = MaterialSymbol("\uE308")
val CellTower = MaterialSymbol("\uEBBA")
val Chat = MaterialSymbol("\uE0C9")
val Check = MaterialSymbol("\uE5CA")
+2
View File
@@ -65,6 +65,7 @@ tarsosdsp = "2.5"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10"
playServicesCast = "22.1.0"
vico-charts-compose = "3.1.0"
zelory = "3.0.1"
zoomable = "2.11.1"
@@ -188,6 +189,7 @@ schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr25
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" }
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }