mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
Vendored
+1
@@ -69,3 +69,4 @@
|
||||
-keep class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
|
||||
+12
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
@@ -154,6 +154,7 @@ fun RenderVideoPlayer(
|
||||
RenderCenterButtons(
|
||||
controllerState = controllerState,
|
||||
controllerVisible = controllerVisible,
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
isLiveStream = isLive,
|
||||
)
|
||||
|
||||
+23
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) {
|
||||
|
||||
+30
-4
@@ -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
|
||||
|
||||
+116
-6
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -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)
|
||||
|
||||
+10
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+37
@@ -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
|
||||
}
|
||||
+519
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+2
@@ -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")
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user