diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 32a65cb98a..57c2f5f539 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -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 diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index e730119cc1..6e21a524cd 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -69,3 +69,4 @@ -keep class * extends androidx.room.RoomDatabase { (); } + diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt new file mode 100644 index 0000000000..61bdddc0af --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -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> = MutableStateFlow>(emptyList()).asStateFlow() + + val sessionState: StateFlow = MutableStateFlow(CastSessionState.Idle).asStateFlow() + + fun startDiscovery() = Unit + + fun stopDiscovery() = Unit + + suspend fun cast( + device: CastDevice, + request: CastRequest, + ) = Unit + + suspend fun stopCasting() = Unit +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 180ce67bd9..466779ad6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 540106e859..ed4d71e424 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 59469a96db..37c277a11c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -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): List { + 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 { val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) val codedList = mutableSetOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt new file mode 100644 index 0000000000..670d34d339 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt new file mode 100644 index 0000000000..3c15c243fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt @@ -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> = caster.devices + val sessionState: StateFlow = 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index c86b117e41..ff17fd2830 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -154,6 +154,7 @@ fun RenderVideoPlayer( RenderCenterButtons( controllerState = controllerState, controllerVisible = controllerVisible, + videoUri = mediaItem.src.videoUri, modifier = Modifier.align(Alignment.Center), isLiveStream = isLive, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt index 1bf719a92e..27a9a6e600 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -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() + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt index edd1f08fa8..4addbc6320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt @@ -81,6 +81,7 @@ fun AnimatedPlayPauseButton( controllerVisible: State, 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt index fd28d1aa5e..676ef286e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -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, + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index a565b2376f..843b83f01e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -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 }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt index 2285a34c08..4ede63da3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt @@ -69,6 +69,7 @@ fun AnimatedSkipButton( controllerVisible: State, 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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt new file mode 100644 index 0000000000..88f567d1aa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 5b647776f9..6dc4f3381c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt index e6cd4ad1fb..e6e6dada17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt @@ -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) { 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) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 967a4bfe42..32ef42f027 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1887,6 +1887,8 @@ Download the video to your device (hidden on live streams) Picture-in-Picture Play the video in a floating window (hidden if unsupported) + Cast to Device + Send the video to a Chromecast or DLNA receiver on your Wi-Fi (hidden for local files) Profile Picture of %1$s Relay %1$s @@ -2555,6 +2557,12 @@ Playback Auto + + Cast to device + Stop casting + Cast to… + Searching for devices on your Wi-Fi… + HLS Upload Publish multi-resolution HLS to your media server diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index d0d019e9a5..aafa18bfb6 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -32,6 +32,12 @@ android:name="com.google.firebase.messaging.default_notification_channel_id" android:value="@string/app_notification_channel_id" /> + + + \ No newline at end of file diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/AmethystCastOptionsProvider.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/AmethystCastOptionsProvider.kt new file mode 100644 index 0000000000..37f1615121 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/AmethystCastOptionsProvider.kt @@ -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? = null +} diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt new file mode 100644 index 0000000000..9529ef6418 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -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>(emptyList()) + val devices: StateFlow> = devicesFlow.asStateFlow() + + private val sessionFlow = MutableStateFlow(CastSessionState.Idle) + val sessionState: StateFlow = 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() + + 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 { + 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? = 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() + // 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() + 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 + } +} diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index c9276464a3..679414e708 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 8bd3931f3b..c2a3b64e01 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -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") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27f67a9cd4..8a38983219 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }