From 5df0e2a37a2198b2b363d07872a71445353ec8fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 08:53:12 +0000 Subject: [PATCH 1/4] Add LAN video casting (Chromecast on play, DLNA on both flavors) v1 fix(cast): handle every Chromecast session terminal state and HLS mime hint fix(cast): repair DLNA Res ctor and add play-only protocol toggle fix(cast): backfill Cast button for accounts with pre-existing video settings fix(cast): include cast glyphs in symbol font subset and add diagnostic logs fix(cast): wire jUPnP's required Jetty deps and survive dialog dismissal fix(cast): add jetty-client so jUPnP can issue UPnP control requests --- amethyst/build.gradle | 19 + amethyst/proguard-rules.pro | 8 + .../cast/chromecast/ChromecastCaster.kt | 57 +++ amethyst/src/main/AndroidManifest.xml | 5 + .../com/vitorpamplona/amethyst/AppModules.kt | 9 + .../amethyst/model/AccountSyncedSettings.kt | 5 +- .../model/AccountSyncedSettingsInternal.kt | 12 + .../amethyst/model/UiSettings.kt | 18 + .../amethyst/model/UiSettingsFlow.kt | 9 + .../model/preferences/UISharedPreferences.kt | 4 + .../amethyst/service/cast/CastDevice.kt | 76 ++++ .../amethyst/service/cast/CastRegistry.kt | 185 ++++++++ .../amethyst/service/cast/VideoCaster.kt | 63 +++ .../amethyst/service/cast/dlna/DlnaCaster.kt | 380 ++++++++++++++++ .../composable/controls/OverflowMenu.kt | 14 + .../composable/controls/RenderTopButtons.kt | 27 ++ .../ui/cast/CastDevicePickerDialog.kt | 124 ++++++ .../loggedIn/settings/AppSettingsScreen.kt | 27 ++ .../settings/VideoPlayerSettingsScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 12 + amethyst/src/play/AndroidManifest.xml | 6 + .../chromecast/AmethystCastOptionsProvider.kt | 37 ++ .../cast/chromecast/ChromecastCaster.kt | 410 ++++++++++++++++++ .../font/material_symbols_outlined.ttf | Bin 418444 -> 425000 bytes .../commons/icons/symbols/MaterialSymbols.kt | 2 + gradle/libs.versions.toml | 14 + 26 files changed, 1523 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/AmethystCastOptionsProvider.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 32a65cb98a..4549d5a047 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -379,6 +379,25 @@ dependencies { //PushNotifications(FDroid) fdroidImplementation libs.unifiedpush + // DLNA / UPnP discovery and AVTransport — used by the LAN cast feature in + // both flavors. jUPnP is the maintained fork of Cling. + implementation libs.jupnp.core + implementation libs.jupnp.android + implementation libs.jupnp.support + // jUPnP-android's AndroidUpnpServiceConfiguration.createStreamServer wires + // in JettyServletContainer (needs jetty-server + jetty-servlet) AND + // createStreamClient wires in JettyStreamClientImpl (needs jetty-client) — + // without all three jUPnP throws NoClassDefFoundError on startup() and + // DLNA discovery never starts. + implementation libs.jetty.server + implementation libs.jetty.servlet + implementation libs.jetty.client + + // 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..e55a4c2d24 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -69,3 +69,11 @@ -keep class * extends androidx.room.RoomDatabase { (); } + +# jUPnP ships optional Jetty + OSGi transport adapters that Android doesn't +# use (we run jupnp-android's Servlet-less transport). Tell R8 to ignore the +# dangling references rather than fail the minify step. +-dontwarn org.eclipse.jetty.** +-dontwarn org.osgi.** +-dontwarn javax.servlet.** +-dontwarn org.jupnp.transport.impl.jetty.** 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..e7fbe561ea --- /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 com.vitorpamplona.amethyst.service.cast.VideoCaster +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 skips Chromecast paths at runtime. + */ +@Suppress("UNUSED_PARAMETER") +class ChromecastCaster( + appContext: Context, +) : VideoCaster { + override val id: String = "chromecast" + + override val devices: StateFlow> = MutableStateFlow>(emptyList()).asStateFlow() + + override val sessionState: StateFlow = MutableStateFlow(CastSessionState.Idle).asStateFlow() + + override fun startDiscovery() = Unit + + override fun stopDiscovery() = Unit + + override suspend fun cast( + device: CastDevice, + request: CastRequest, + ) = Unit + + override suspend fun stopCasting() = Unit +} diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index f49058bee7..9c914026f2 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -42,6 +42,11 @@ (otherwise the wrapper waits ~30s for QUIC PTO before reconnecting). --> + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 180ce67bd9..cc358f57b0 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 — aggregates Chromecast (play flavor only) and DLNA + // discovery into a single device list. 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, applicationIOScope, uiPrefs.value.castProtocol) + } + // 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/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index e1ebf5fa8c..2b043bb775 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -47,6 +47,7 @@ data class UiSettings( val showHomeNewThreadsTab: Boolean = true, val showHomeConversationsTab: Boolean = true, val showHomeEverythingTab: Boolean = false, + val castProtocol: CastProtocolType = CastProtocolType.BOTH, ) enum class ThemeType( @@ -147,6 +148,23 @@ fun parseBooleanType(screenCode: Int): BooleanType = else -> BooleanType.ALWAYS } +enum class CastProtocolType( + val screenCode: Int, + val resourceId: Int, +) { + BOTH(0, R.string.cast_protocol_both), + CHROMECAST(1, R.string.cast_protocol_chromecast), + DLNA(2, R.string.cast_protocol_dlna), +} + +fun parseCastProtocolType(screenCode: Int): CastProtocolType = + when (screenCode) { + CastProtocolType.BOTH.screenCode -> CastProtocolType.BOTH + CastProtocolType.CHROMECAST.screenCode -> CastProtocolType.CHROMECAST + CastProtocolType.DLNA.screenCode -> CastProtocolType.DLNA + else -> CastProtocolType.BOTH + } + enum class WarningType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 8a57956362..ef96a8b970 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -47,6 +47,7 @@ class UiSettingsFlow( val showHomeNewThreadsTab: MutableStateFlow = MutableStateFlow(true), val showHomeConversationsTab: MutableStateFlow = MutableStateFlow(true), val showHomeEverythingTab: MutableStateFlow = MutableStateFlow(false), + val castProtocol: MutableStateFlow = MutableStateFlow(CastProtocolType.BOTH), ) { val listOfFlows: List> = listOf>( @@ -68,6 +69,7 @@ class UiSettingsFlow( showHomeNewThreadsTab, showHomeConversationsTab, showHomeEverythingTab, + castProtocol, ) // emits at every change in any of the propertyes. @@ -93,6 +95,7 @@ class UiSettingsFlow( flows[15] as Boolean, flows[16] as Boolean, flows[17] as Boolean, + flows[18] as CastProtocolType, ) } @@ -116,6 +119,7 @@ class UiSettingsFlow( showHomeNewThreadsTab.value, showHomeConversationsTab.value, showHomeEverythingTab.value, + castProtocol.value, ) fun update(torSettings: UiSettings): Boolean { @@ -193,6 +197,10 @@ class UiSettingsFlow( showHomeEverythingTab.tryEmit(torSettings.showHomeEverythingTab) any = true } + if (castProtocol.value != torSettings.castProtocol) { + castProtocol.tryEmit(torSettings.castProtocol) + any = true + } return any } @@ -230,6 +238,7 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.showHomeNewThreadsTab), MutableStateFlow(uiSettings.showHomeConversationsTab), MutableStateFlow(uiSettings.showHomeEverythingTab), + MutableStateFlow(uiSettings.castProtocol), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 28be3d53e4..3aef11fa25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -32,6 +32,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.BooleanType +import com.vitorpamplona.amethyst.model.CastProtocolType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.ProfileGalleryType @@ -108,6 +109,7 @@ class UiSharedPreferences( val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items") + val UI_CAST_PROTOCOL = stringPreferencesKey("ui.cast_protocol") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -134,6 +136,7 @@ class UiSharedPreferences( preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems, + castProtocol = preferences[UI_CAST_PROTOCOL]?.let { CastProtocolType.valueOf(it) } ?: CastProtocolType.BOTH, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -173,6 +176,7 @@ class UiSharedPreferences( preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name } + preferences[UI_CAST_PROTOCOL] = sharedSettings.castProtocol.name } } catch (e: Exception) { if (e is CancellationException) throw e 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..a17e208656 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -0,0 +1,76 @@ +/* + * 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 + +enum class CastDeviceKind { + Chromecast, + Dlna, +} + +@Immutable +data class CastDevice( + val id: String, + val name: String, + val kind: CastDeviceKind, + val casterId: String, +) + +@Immutable +data class CastRequest( + val url: String, + val mimeType: String? = null, + val title: String? = null, + val artworkUri: String? = null, +) + +// When the caller didn't supply a mime hint, fall back to a best-guess from the +// URL extension. Critical for HLS: the default Cast receiver crashes loading +// `.m3u8` content sent as `video/mp4` because it tries to demux a playlist as +// MP4 — wiping the TV's Cast service from the network in the process. +fun CastRequest.effectiveMimeType(): String = + mimeType?.takeIf { it.isNotBlank() } + ?: when { + url.endsWith(".m3u8", ignoreCase = true) -> "application/vnd.apple.mpegurl" + url.endsWith(".mpd", ignoreCase = true) -> "application/dash+xml" + url.endsWith(".webm", ignoreCase = true) -> "video/webm" + url.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..776b452dbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt @@ -0,0 +1,185 @@ +/* + * 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.model.CastProtocolType +import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster +import com.vitorpamplona.amethyst.service.cast.dlna.DlnaCaster +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +private const val TAG = "CastRegistry" + +/** + * Aggregates every [VideoCaster] implementation available on this build into + * a single device list and a single active-session view, so the cast UI + * doesn't care whether a device speaks Chromecast or DLNA. + * + * Discovery is started/stopped explicitly by callers (typically the picker + * dialog's DisposableEffect) so that SSDP multicast traffic + the + * MulticastLock are only paid for while the user is choosing. + * + * The registry honours [protocolFlow]: when the user has narrowed cast to a + * single protocol, the disabled caster never starts discovery and its devices + * are filtered out of [devices]. Switching protocols mid-session leaves any + * active cast running — only future discovery is affected. + */ +class CastRegistry( + appContext: Context, + scope: CoroutineScope, + private val protocolFlow: StateFlow = + MutableStateFlow(CastProtocolType.BOTH), +) { + private val casters: List = + listOf( + ChromecastCaster(appContext), + DlnaCaster(appContext), + ) + + val devices: StateFlow> = + combine( + combine(casters.map { it.devices }) { snapshots -> snapshots.toList() }, + protocolFlow, + ) { snapshots, proto -> + val merged = + snapshots.flatMapIndexed { index, list -> + if (isCasterEnabled(casters[index], proto)) list else emptyList() + } + Log.d(TAG) { + "devices flow update proto=$proto " + + snapshots.mapIndexed { i, l -> "${casters[i].id}=${l.size}" }.joinToString() + + " merged=${merged.size} -> [${merged.joinToString { "${it.casterId}:${it.name}" }}]" + } + merged + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + + val sessionState: StateFlow = + combine(casters.map { it.sessionState }) { states -> + states.firstOrNull { it !is CastSessionState.Idle } ?: CastSessionState.Idle + }.stateIn(scope, SharingStarted.Eagerly, CastSessionState.Idle) + + private val refCount = AtomicInteger(0) + private val active = + ConcurrentHashMap().apply { + casters.forEach { put(it.id, false) } + } + + init { + Log.d(TAG) { + "init casters=[${casters.joinToString { it.id }}] initialProtocol=${protocolFlow.value}" + } + // While the picker is open, react to protocol changes by stopping/starting + // the affected casters. Idle case is a no-op because reconcile only runs + // when refCount > 0. + scope.launch { + protocolFlow.collect { proto -> + Log.d(TAG) { "protocolFlow change -> $proto refCount=${refCount.get()}" } + if (refCount.get() > 0) reconcile() + } + } + } + + private fun isCasterEnabled( + caster: VideoCaster, + proto: CastProtocolType, + ): Boolean = + when (proto) { + CastProtocolType.BOTH -> true + CastProtocolType.CHROMECAST -> caster.id == "chromecast" + CastProtocolType.DLNA -> caster.id == "dlna" + } + + @Synchronized + private fun reconcile() { + val proto = protocolFlow.value + casters.forEach { caster -> + val shouldRun = isCasterEnabled(caster, proto) + val running = active[caster.id] == true + if (shouldRun && !running) { + Log.d(TAG) { "reconcile START ${caster.id} (proto=$proto)" } + runCatching { caster.startDiscovery() } + .onFailure { Log.w(TAG, "reconcile START ${caster.id} threw: ${it.message}", it) } + active[caster.id] = true + } else if (!shouldRun && running) { + Log.d(TAG) { "reconcile STOP ${caster.id} (proto=$proto)" } + runCatching { caster.stopDiscovery() } + .onFailure { Log.w(TAG, "reconcile STOP ${caster.id} threw: ${it.message}", it) } + active[caster.id] = false + } + } + } + + @Synchronized + private fun stopAll() { + Log.d(TAG) { "stopAll active=${active.filterValues { it }.keys}" } + casters.forEach { caster -> + if (active[caster.id] == true) { + runCatching { caster.stopDiscovery() } + .onFailure { Log.w(TAG, "stopAll ${caster.id} threw: ${it.message}", it) } + active[caster.id] = false + } + } + } + + fun startDiscovery() { + val before = refCount.getAndIncrement() + Log.d(TAG) { "startDiscovery refCount $before -> ${before + 1}" } + if (before == 0) reconcile() + } + + fun stopDiscovery() { + val after = refCount.decrementAndGet() + Log.d(TAG) { "stopDiscovery refCount -> $after" } + if (after <= 0) { + refCount.set(0) + stopAll() + } + } + + suspend fun cast( + device: CastDevice, + request: CastRequest, + ) { + Log.d(TAG) { + "cast device=${device.casterId}:${device.name} url=${request.url} mime=${request.mimeType}" + } + val caster = casters.firstOrNull { it.id == device.casterId } + if (caster == null) { + Log.w(TAG, "cast: no caster found for casterId=${device.casterId}") + return + } + caster.cast(device, request) + } + + suspend fun stopCasting() { + Log.d(TAG) { "stopCasting (broadcast to all casters)" } + casters.forEach { runCatching { it.stopCasting() } } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt new file mode 100644 index 0000000000..a7ad156a8e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt @@ -0,0 +1,63 @@ +/* + * 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 kotlinx.coroutines.flow.StateFlow + +/** + * Common contract for any LAN cast protocol that Amethyst can drive — currently + * Chromecast (Google Cast SDK, play flavor only) and DLNA / UPnP AVTransport + * (jUPnP, both flavors). The registry layer aggregates one or more of these + * to give the UI a unified device list and active-session state. + */ +interface VideoCaster { + /** Stable identifier for diagnostics and merging device IDs across casters. */ + val id: String + + /** Discovered receiver devices; emits a snapshot whenever the set changes. */ + val devices: StateFlow> + + /** Status of the active casting session owned by this caster. */ + val sessionState: StateFlow + + /** + * Called by the registry while at least one observer wants device updates + * (typically: the picker dialog is open, or a session is active). + * Implementations should be idempotent. + */ + fun startDiscovery() + + /** Counterpart to [startDiscovery]. Idempotent; no-op when not running. */ + fun stopDiscovery() + + /** + * Hand a video off to [device] and start playback. Updates [sessionState] + * to Connecting → Casting (or Error). [device] must be one this caster + * emitted in [devices] — the registry routes by [CastDevice.casterId]. + */ + suspend fun cast( + device: CastDevice, + request: CastRequest, + ) + + /** End the active session if any. Idempotent. */ + suspend fun stopCasting() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt new file mode 100644 index 0000000000..ea4f40864d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt @@ -0,0 +1,380 @@ +/* + * 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.dlna + +import android.content.Context +import android.net.wifi.WifiManager +import com.vitorpamplona.amethyst.service.cast.CastDevice +import com.vitorpamplona.amethyst.service.cast.CastDeviceKind +import com.vitorpamplona.amethyst.service.cast.CastRequest +import com.vitorpamplona.amethyst.service.cast.CastSessionState +import com.vitorpamplona.amethyst.service.cast.VideoCaster +import com.vitorpamplona.amethyst.service.cast.effectiveMimeType +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jupnp.UpnpService +import org.jupnp.UpnpServiceImpl +import org.jupnp.android.AndroidUpnpServiceConfiguration +import org.jupnp.model.action.ActionInvocation +import org.jupnp.model.message.UpnpResponse +import org.jupnp.model.message.header.STAllHeader +import org.jupnp.model.meta.LocalDevice +import org.jupnp.model.meta.RemoteDevice +import org.jupnp.model.types.UDADeviceType +import org.jupnp.model.types.UDAServiceType +import org.jupnp.model.types.UDN +import org.jupnp.registry.Registry +import org.jupnp.registry.RegistryListener +import org.jupnp.support.avtransport.callback.Play +import org.jupnp.support.avtransport.callback.SetAVTransportURI +import org.jupnp.support.avtransport.callback.Stop +import org.jupnp.support.contentdirectory.DIDLParser +import org.jupnp.support.model.DIDLContent +import org.jupnp.support.model.ProtocolInfo +import org.jupnp.support.model.Res +import org.jupnp.support.model.item.VideoItem +import java.util.concurrent.ConcurrentHashMap + +private const val TAG = "DlnaCaster" + +private val MEDIA_RENDERER = UDADeviceType("MediaRenderer") +private val AV_TRANSPORT = UDAServiceType("AVTransport") + +class DlnaCaster( + private val appContext: Context, +) : VideoCaster { + override val id: String = "dlna" + + private val devicesFlow = MutableStateFlow>(emptyList()) + override val devices: StateFlow> = devicesFlow.asStateFlow() + + private val sessionFlow = MutableStateFlow(CastSessionState.Idle) + override val sessionState: StateFlow = sessionFlow.asStateFlow() + + private val knownDevices = ConcurrentHashMap() + private var multicastLock: WifiManager.MulticastLock? = null + + @Volatile + private var upnpService: UpnpService? = null + + private val listener = + object : RegistryListener { + override fun remoteDeviceDiscoveryStarted( + registry: Registry, + device: RemoteDevice, + ) { + Log.d(TAG) { "discoveryStarted udn=${device.identity.udn} type=${device.type}" } + } + + override fun remoteDeviceDiscoveryFailed( + registry: Registry, + device: RemoteDevice, + ex: Exception?, + ) { + Log.w(TAG, "discoveryFailed udn=${device.identity.udn} type=${device.type} ex=${ex?.message}", ex) + } + + override fun remoteDeviceAdded( + registry: Registry, + device: RemoteDevice, + ) { + val isRenderer = device.type.implementsVersion(MEDIA_RENDERER) + Log.d(TAG) { + "remoteDeviceAdded udn=${device.identity.udn} type=${device.type} renderer=$isRenderer name=${device.details?.friendlyName}" + } + if (isRenderer) addDevice(device) + } + + override fun remoteDeviceUpdated( + registry: Registry, + device: RemoteDevice, + ) { + if (device.type.implementsVersion(MEDIA_RENDERER)) addDevice(device) + } + + override fun remoteDeviceRemoved( + registry: Registry, + device: RemoteDevice, + ) { + Log.d(TAG) { "remoteDeviceRemoved udn=${device.identity.udn}" } + removeDevice(device.identity.udn.identifierString) + } + + override fun localDeviceAdded( + registry: Registry, + device: LocalDevice, + ) = Unit + + override fun localDeviceRemoved( + registry: Registry, + device: LocalDevice, + ) = Unit + + override fun beforeShutdown(registry: Registry) = Unit + + override fun afterShutdown() = Unit + } + + private fun addDevice(device: RemoteDevice) { + val udn = device.identity.udn.identifierString + knownDevices[udn] = device + publish() + } + + private fun removeDevice(udn: String) { + knownDevices.remove(udn) + publish() + } + + private fun publish() { + devicesFlow.value = + knownDevices.values.map { device -> + CastDevice( + id = device.identity.udn.identifierString, + name = device.details?.friendlyName ?: device.displayString, + kind = CastDeviceKind.Dlna, + casterId = id, + ) + } + } + + @Synchronized + override fun startDiscovery() { + Log.d(TAG) { "startDiscovery (already running? ${upnpService != null})" } + if (upnpService != null) return + try { + acquireMulticastLock() + val service = UpnpServiceImpl(AndroidUpnpServiceConfiguration()) + service.startup() + service.registry.addListener(listener) + // Republish anything already in the registry (covers reuse). + val preexisting = + service.registry.remoteDevices + .filter { it.type.implementsVersion(MEDIA_RENDERER) } + Log.d(TAG) { "startDiscovery: preexisting renderers=${preexisting.size}" } + preexisting.forEach { addDevice(it) } + service.controlPoint.search(STAllHeader()) + upnpService = service + Log.d(TAG) { "startDiscovery: jUPnP started, SSDP M-SEARCH sent (multicastLock held=${multicastLock?.isHeld})" } + } catch (t: Throwable) { + Log.w(TAG, "startDiscovery failed: ${t.message}", t) + releaseMulticastLock() + } + } + + @Synchronized + override fun stopDiscovery() { + Log.d(TAG) { "stopDiscovery (running? ${upnpService != null})" } + val service = upnpService ?: return + try { + service.registry.removeListener(listener) + service.shutdown() + } catch (t: Throwable) { + Log.w(TAG, "stopDiscovery failed: ${t.message}", t) + } + upnpService = null + knownDevices.clear() + publish() + releaseMulticastLock() + Log.d(TAG) { "stopDiscovery: jUPnP stopped, multicast lock released" } + } + + private fun acquireMulticastLock() { + if (multicastLock != null) return + try { + val wifi = appContext.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + if (wifi == null) { + Log.w(TAG, "acquireMulticastLock: WifiManager unavailable") + return + } + val lock = wifi.createMulticastLock("amethyst-dlna-cast") + lock.setReferenceCounted(false) + lock.acquire() + multicastLock = lock + Log.d(TAG) { "acquireMulticastLock: held=${lock.isHeld}" } + } catch (t: Throwable) { + Log.w(TAG, "acquireMulticastLock failed: ${t.message}", t) + } + } + + private fun releaseMulticastLock() { + try { + multicastLock?.takeIf { it.isHeld }?.release() + } catch (t: Throwable) { + Log.w(TAG, "releaseMulticastLock failed: ${t.message}", t) + } + multicastLock = null + } + + override suspend fun cast( + device: CastDevice, + request: CastRequest, + ) { + Log.d(TAG) { "cast device=${device.name} udn=${device.id} url=${request.url}" } + val service = upnpService + if (service == null) { + Log.w(TAG, "cast: jUPnP service not running") + sessionFlow.value = CastSessionState.Error(device, "DLNA service not running") + return + } + val remoteDevice = knownDevices[device.id] ?: service.registry.getRemoteDevice(UDN.valueOf(device.id), true) + if (remoteDevice == null) { + Log.w(TAG, "cast: device ${device.id} not in registry") + sessionFlow.value = CastSessionState.Error(device, "Device went offline") + return + } + val avTransport = remoteDevice.findService(AV_TRANSPORT) + if (avTransport == null) { + Log.w(TAG, "cast: device ${device.id} has no AVTransport service") + sessionFlow.value = CastSessionState.Error(device, "Device does not advertise AVTransport") + return + } + + sessionFlow.value = CastSessionState.Connecting(device) + + val metadata = buildDidlMetadata(request) + Log.d(TAG) { "cast: SetAVTransportURI -> ${request.url} (didl=${metadata.length}b)" } + + val setUriDone = CompletableDeferred() + service.controlPoint.execute( + object : SetAVTransportURI(avTransport, request.url, metadata) { + override fun success(invocation: ActionInvocation<*>?) { + setUriDone.complete(true) + } + + override fun failure( + invocation: ActionInvocation<*>?, + operation: UpnpResponse?, + defaultMsg: String?, + ) { + Log.w(TAG, "SetAVTransportURI failed: $defaultMsg") + setUriDone.complete(false) + } + }, + ) + + val uriOk = setUriDone.await() + if (!uriOk) { + sessionFlow.value = CastSessionState.Error(device, "Receiver rejected the media URL") + return + } + + val playDone = CompletableDeferred() + service.controlPoint.execute( + object : Play(avTransport) { + override fun success(invocation: ActionInvocation<*>?) { + playDone.complete(true) + } + + override fun failure( + invocation: ActionInvocation<*>?, + operation: UpnpResponse?, + defaultMsg: String?, + ) { + Log.w(TAG, "Play failed: $defaultMsg") + playDone.complete(false) + } + }, + ) + + if (playDone.await()) { + Log.d(TAG) { "cast: Play succeeded" } + sessionFlow.value = CastSessionState.Casting(device, request) + } else { + Log.w(TAG, "cast: Play action rejected by receiver") + sessionFlow.value = CastSessionState.Error(device, "Receiver rejected Play action") + } + } + + override suspend fun stopCasting() { + Log.d(TAG) { "stopCasting (running? ${upnpService != null}, state=${sessionFlow.value::class.simpleName})" } + val service = + upnpService ?: run { + sessionFlow.value = CastSessionState.Idle + return + } + val current = sessionFlow.value + val device = + when (current) { + is CastSessionState.Casting -> { + current.device + } + + is CastSessionState.Connecting -> { + current.device + } + + else -> { + sessionFlow.value = CastSessionState.Idle + return + } + } + val remoteDevice = knownDevices[device.id] + val avTransport = remoteDevice?.findService(AV_TRANSPORT) + if (avTransport != null) { + val done = CompletableDeferred() + service.controlPoint.execute( + object : Stop(avTransport) { + override fun success(invocation: ActionInvocation<*>?) { + done.complete(Unit) + } + + override fun failure( + invocation: ActionInvocation<*>?, + operation: UpnpResponse?, + defaultMsg: String?, + ) { + done.complete(Unit) + } + }, + ) + done.await() + } + sessionFlow.value = CastSessionState.Idle + } + + private fun buildDidlMetadata(request: CastRequest): String = + try { + val mime = request.effectiveMimeType() + val res = Res(ProtocolInfo("http-get:*:$mime:*"), null as Long?, request.url) + val item = + VideoItem( + // id = + "amethyst-cast-1", + // parentID = + "0", + // title = + request.title ?: "Amethyst", + // creator = + "Amethyst", + res, + ) + val didl = DIDLContent() + didl.addItem(item) + DIDLParser().generate(didl) + } catch (t: Throwable) { + Log.w(TAG, "Could not build DIDL metadata, sending empty: ${t.message}", t) + "" + } +} 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..54468f21ad 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 @@ -70,6 +70,7 @@ fun OverflowMenuButtonPreview() { onShareClick = {}, onSaveClick = {}, onPipClick = {}, + onCastClick = {}, ) } } @@ -86,6 +87,7 @@ fun AnimatedOverflowMenuButton( onShareClick: () -> Unit, onSaveClick: () -> Unit, onPipClick: () -> Unit, + onCastClick: () -> Unit, modifier: Modifier = Modifier, ) { AnimatedVisibility( @@ -103,6 +105,7 @@ fun AnimatedOverflowMenuButton( onShareClick = onShareClick, onSaveClick = onSaveClick, onPipClick = onPipClick, + onCastClick = onCastClick, ) } } @@ -117,6 +120,7 @@ fun OverflowMenuButton( onShareClick: () -> Unit, onSaveClick: () -> Unit, onPipClick: () -> Unit, + onCastClick: () -> Unit, ) { val menuExpanded = remember { mutableStateOf(false) } @@ -211,6 +215,16 @@ fun OverflowMenuButton( onPipClick() } } + + VideoPlayerAction.Cast -> { + M3ActionRow( + icon = MaterialSymbols.Cast, + text = stringRes(R.string.cast_to_device), + ) { + menuExpanded.value = false + onCastClick() + } + } } } } 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..5c12f64971 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 @@ -55,11 +55,13 @@ 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.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 @@ -197,6 +199,7 @@ fun RenderTopButtons( ) { val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle() val shareDialogVisible = remember { mutableStateOf(false) } + val castDialogVisible = remember { mutableStateOf(false) } val saveAction = rememberSaveMediaAction { context -> accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) @@ -210,6 +213,7 @@ fun RenderTopButtons( VideoPlayerAction.Share -> true VideoPlayerAction.Download -> !isLive VideoPlayerAction.PictureInPicture -> pipSupported + VideoPlayerAction.Cast -> mediaData.videoUri.startsWith("http", ignoreCase = true) } val canFullscreen = onZoomClick != null @@ -281,6 +285,15 @@ fun RenderTopButtons( onClick = onPictureInPictureClick, ) } + + VideoPlayerAction.Cast -> { + AnimatedTopBarIconButton( + controllerVisible = controllerVisible, + symbol = MaterialSymbols.Cast, + contentDescription = stringRes(R.string.cast_to_device), + onClick = { castDialogVisible.value = true }, + ) + } } } @@ -295,6 +308,20 @@ fun RenderTopButtons( onShareClick = { shareDialogVisible.value = true }, onSaveClick = saveAction, onPipClick = onPictureInPictureClick, + onCastClick = { castDialogVisible.value = true }, + ) + } + + 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/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt new file mode 100644 index 0000000000..b9f2ac1f3c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -0,0 +1,124 @@ +/* + * 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.LaunchedEffect +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.CastDeviceKind +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, +) { + Log.d(TAG) { "open url=${request.url} mime=${request.mimeType}" } + LaunchedEffect(registry) { + registry.startDiscovery() + } + androidx.compose.runtime.DisposableEffect(registry) { + 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 -> + val icon = + when (device.kind) { + CastDeviceKind.Chromecast -> MaterialSymbols.Cast + CastDeviceKind.Dlna -> MaterialSymbols.CastConnected + } + M3ActionRow( + icon = icon, + text = device.name, + ) { + Log.d(TAG) { "tap device=${device.casterId}:${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..805e1d80db 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,14 +53,17 @@ 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.CastProtocolType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.parseBooleanType +import com.vitorpamplona.amethyst.model.parseCastProtocolType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType @@ -128,6 +131,9 @@ fun SettingsScreen( ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) + if (BuildConfig.FLAVOR == "play") { + CastProtocolChoice(sharedPrefs) + } ShowUrlPreviewChoice(sharedPrefs) ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) @@ -280,6 +286,27 @@ fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) { } } +@Composable +fun CastProtocolChoice(sharedPrefs: UiSettingsFlow) { + val castProtocolOptions = + persistentListOf( + TitleExplainer(stringRes(CastProtocolType.BOTH.resourceId)), + TitleExplainer(stringRes(CastProtocolType.CHROMECAST.resourceId)), + TitleExplainer(stringRes(CastProtocolType.DLNA.resourceId)), + ) + + val castProtocolIndex by sharedPrefs.castProtocol.collectAsState() + + SettingsRow( + R.string.cast_protocol_setting_title, + R.string.cast_protocol_setting_description, + castProtocolOptions, + castProtocolIndex.screenCode, + ) { + sharedPrefs.castProtocol.tryEmit(parseCastProtocolType(it)) + } +} + @Composable fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) { val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState() 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..5b8e921919 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 @@ -330,6 +330,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 +342,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..bbb7aef1a9 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,16 @@ Playback Auto + + Cast to device + Cast to… + Searching for devices on your Wi-Fi… + Cast protocol + Which devices to scan for when casting videos on your Wi-Fi + Chromecast & UPnP + Chromecast only + UPnP / DLNA only + 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..0283f63b88 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -0,0 +1,410 @@ +/* + * 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.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.SessionManagerListener +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.CastDeviceKind +import com.vitorpamplona.amethyst.service.cast.CastRequest +import com.vitorpamplona.amethyst.service.cast.CastSessionState +import com.vitorpamplona.amethyst.service.cast.VideoCaster +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 + +/** + * Google Cast (Chromecast) implementation of [VideoCaster]. + * + * 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, +) : VideoCaster { + override val id: String = "chromecast" + + private val devicesFlow = MutableStateFlow>(emptyList()) + override val devices: StateFlow> = devicesFlow.asStateFlow() + + private val sessionFlow = MutableStateFlow(CastSessionState.Idle) + override 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 + + 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 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}" } + 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. + 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. + 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 } + } catch (t: Throwable) { + Log.w(TAG, "Failed to initialize CastContext: ${t.message}", t) + null + } + } + + override 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) + ctx.sessionManager.addSessionManagerListener(sessionListener, CastSession::class.java) + mediaRouter = router + routeSelector = selector + registered = true + Log.d(TAG) { "startDiscovery: registered router callback + session listener" } + updateRoutes(router) + } + } + + override fun stopDiscovery() { + Log.d(TAG) { "stopDiscovery (registered=$registered)" } + main.post { + if (!registered) return@post + mediaRouter?.removeCallback(routerCallback) + castContext?.sessionManager?.removeSessionManagerListener(sessionListener, CastSession::class.java) + registered = false + routes.clear() + devicesFlow.value = emptyList() + Log.d(TAG) { "stopDiscovery: unregistered" } + } + } + + 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, + kind = CastDeviceKind.Chromecast, + casterId = id, + ) + } + Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" } + devicesFlow.value = list + } + + override 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() + 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.message}", 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.message}", 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() + } + + override suspend fun stopCasting() { + Log.d(TAG) { "stopCasting" } + withContext(Dispatchers.Main) { + try { + castContext?.sessionManager?.endCurrentSession(true) + } catch (t: Throwable) { + Log.w(TAG, "endCurrentSession failed: ${t.message}", 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 c9276464a3c5179247eb8b91709096843459bdce..679414e7085a236808be26b926b6ff6c4594b627 100644 GIT binary patch delta 9120 zcmb7K34B!5x&O{R_s*7^*)nUknaRGeC4{gFfj|t45Ed<9i-biHL8(Q=%Pflt-}EipsOp0!5$@FrtkE1_;?m!jM^#nR(y2lT0QF`rbQ}v)ps|L7i?OzaWBBBG62ps zZ{f)NscY{m0yy|QzO{VeqN$6XYv1@wfPam`>!anH${z*s`T@Ll<8|4}_2nB@{%TJK zKHmr+i@zW5Z@;;IKTei~_gfw)|Cw_GHnce5F{~92tb5{qST(r-KrjIOVaEgbLhiPe ztpI1IgYeGfHLJ^4jZI2_3gAzh@%h3v*dhN;Zo~;z;dR=Y^&2@EYj@Vo>=D^N&#uo7LD zEvF{uYOa_&BX?Eq%ejBd{W7<8SoW~_!yX^@-SEWW+lC(*?jMmh;{3?+k-JBp8AV5B zjk;^pmQh#o=I5=>+m!cQ-fMa9e16j zZyLR4^j8Jyg82n+7j%w^AG2c28)N=9=KPpzh04N-g{6f*DSW)}?ZU6d#*B50oji8O z*iXj^<4VRoG_GR&u<5n(|4JqG(Fd(xO+3{x;P(_1>xPPxVeqo3?4%htsYV zL$RqiwRlSLg5veX6~*rre_DL8_*!vb`kd*X&T!0lV8*T)7fSe&y(KsAEWh)3saU$H z^!QBs%+i@PGn;3X&e}BV>)E>5J7-^-^V2!U@A}DIpU*9v`|#X@b6xY6&f7oFQYUEzYL0jkKlPl(wnK&B<>hzmWW(tulEuK=PsF z{doOe^4rOylV>H*NLtI@PU=Z|F|j#uUE&LgZzZV{pG+(SNW6|YE-@!jov#g{__)=?!^$qI+%TdcXD+RC|uq-q0Hg~YWEl-($ zZOO+PaTLIO+I-CXrFolqq2&q7L*{PFCiAD}1nj?Io@&W9T{N9DFEKx3o@D;B=_~V8 zvkky>#?)iFY5LCe7Pjvb3v3|9>e#-AF88D7xEYQNCkr@Npn zXA=QvE@?bkQFBzY1EbM;yzbU4*HmJCLG!Ifp+2O3QUmHjO}0j-#$Xb2KIX^rwbGJ> zUzbW!hHFtq7WH>kzjweo6EDfA{=U8zv6Jw|?CX$fV#22}_a`EnKwO~Bpf z1#abEfLr}Ja2rJ69=iv)r?@;pYYv7df@(i1h|j(0Qa#AxX+dXca#I}^Df|y)q;~dc|UMpJPX{}$-vb< z1Kb51pd)?_bmSJGqt*ex5Ge&8QH2K?i@fPdx!@LLms-?0h!oe0(ZFJA)wbsY4aEx_-`iQaDk{?pfi z|Jxzpzc>v1IV6!6-N65QIq+ATfN$CeeCPAP-zo(@;0A##2LXLT(Cq*rDGP)YF9;)6 zfKc#j5Q>UGn7$c=nZE#G&Lj|ignd7L7lc&>AlzRE!oy4Pg-5aTsc9fMUk71p2?)<0 z0b%Dl5Pq=~gf}*T@Sl4?*t@&xNbPe+YRLyjYPluTomN-_*$@W{>~dIT=b_jRFyF3% zwRR4c*$J{u6HK<7vD9GJfx~XW+6Z^smH3Pd-&4Q_y8x^0F|gQ9!DKh!^J+-9YoWld zhirQ+J};`2Krm71_vqp~Lk|Xzu%8o=O#00XX+s~v+X#T|;U8XWkW9{WiRMSOjm2a zDJZar{26<4(m*3K6H9iG?96dVZjpD3_4V~$(dkyCU{ch#wbeHV0=^sdje?j|-z2(u zvGVdE(mSY zb;qP+3%#iu6Crz;A)XbmVC%mXbCvSUxlV;b;l-r1aNoHaFcVSYt_(VBt zw6p+s-5cz+0cmp`uHb{-h|&H4p;wi>pNgXB#-tRfpJxqGOo$)s%!G5YMU~tmUN9Pk zZlAZSTR`1<2=RBxV&q(RSC6QT_xSuh)M%m#1?>rVJbq3d>Y;K`rxQFW4jh%pWdjzs z*U(ah!#e`aEv*46YPCul@U&y{Xk*YKTA9=9^>bQX$pEeFnNMhsnzf* zt&+z_iQm)I?!)aEo(km+J0-d`ZcXMv#3~pKO#ZGu`NQIO@uJr1@iOTn;&*d$kB2IB zqO3d3iC)R1XEx|h;daE~xzwI3R~vhHxyE1+`5rf}NQ>JL%a%FT(ADLm2Bo+4N@|>1 zP6OAjGz4z7V!9QeF>!icjEh5OlgDyCcSEx$&S94j1(%))&DNh)RKkPaeZbp89id=H ztU6W|3WXH09ax4!v7t~fB*cb-5aMDhl{7}(+1c3>==5yiJ)LT0OrWq#FAWGv8QxMN zn28XfVyk3So01s_P|(y}^l4dQOUu%3AiIEU(`meQwQMN2;FLqd=StG}!F<)=@`3{5T%S*RVNPPUx7PD6Vuhf)~uuJu*M+wK;M;0V{jUNha>J%`a>M0G=ND- zxqyc?D6J5YXqI4LT8x3|4h&3lFff&1V7eOv(>x47OYvxACI%pkZy2;`D95nQa3G8i#-MD7Fv~-g8;UdA+8xVS2?YX)3 zm>d1kLMKb`UB+N=xg{vZ4ZqwGn=&9$#OcH1qTiu#)Jbs%37oz^A_#S7D2owZeRMd8 zlLCa@Vn$`6A_3Dhte$yb9AJ(ng$E08lG2aG)kWLcw`!l3Va?<69S-Iqfz+Vng&2*mD$ALL$V=Ly?;eaT|NG;0K;$w zf<~<(42^;*;2{wNbaqDxY3Rc=gPa97A5~-6@wHsR)Z&Z5jnoHzw|c5_^gJ)>^&*eK zMTRx!b-No7;S|Vryv+>*K(H#_igNY2O}{gvxQKsN#I_*I4B4;+5zy;^m`Zscs8#X^ zZGCVDY<+$X|Fk`L3tV0TF!P@q3B$uv_3l4ub);Pw#Z6I^ELmoHvbCkSxOgfe zP80_n!cnujyE%=qxw#`K3?!pN$f_-Q!2IYIefz(Uv{odYJ$J5F@%6>8FGk~1guW(g zwQ3borLNBQXpD;RSY}I1s3C{8?DIR;6XxD*8#|10rPh$`Jbk)$AaX@fTV+ejwQuXP zvQiA~mAYsQ>*ct_&@y~jWZ4u937I3}PokSlhDeg2gCx{7-13VJU8xg$G0xdbYNJ`N z5C%L;R#p&MTlb~9f$TOye-#n>o0-w8Fo({=49Zkz<$!MulqJI*>~`O1NJvcR1x9C> z@%9tqUUbY5MV&dZq2Y#m0QDV0jJFG&=gyu!)p>Zkpil@wzaJYdR@<237Nov!La1uH z%xIK#HaC+>=5P|{#d<4)Rr2O`!KtV{|JBzD>2=!U^z9Zg)T>id!M{{&u0^#;ury2JB2sRJAoU98OD(UUa5En}eAEi^vvA70qU&S#C{C z3!}I*!f~VNZ>q=R?dd^6pcsVc(L6Xer*A?+*xL7*U7B<-BhCr5wza!e-`3Z=SR`Ok zK;pg5j^?4CJd8b4p53ZIcPrPI554 z`>CuJ2+)**g*;X<7hU7`px zBh}79ta=!wLC?+DSS1&TL+%_+oo_?0`=N|&bWFWlkjI7);nV9NI1fI3iO8u$SXPJ;C5%i80@c5fqP<@Ms3`2uFp{lf!F*$2&^c zgpslwBjqNHlVpAHf7XEU$H2i=Y03O-|u_hd;fQ4Yqx&+ zA-w|(Krfmk)R|+7(pFa1?g2h0kS)j>YcH5r|4J8dYyrT(7(FI3+CFQ_P~f9ZGMGB1 z&|dgvXXP5;L?OwoCG$(>!u#7spk0!j_u}l5Iqe5#CIJn3fXb(o^nblS`xBr|NBY|{ zO6ED{&_WFy?I!Lyqx|*KDL*~%q~rm>X3QXmq`dGp3Rnom8DBQNWLn&n*0I3Wn`9qX zMu5kEJp8Gk<0SVln_XFzADz1b_|QTHHR5etK8SVWH_*^9O(0w%TJk^t%?R2P>=`^VxHkCU0ONph0~!W=6EZ4fO~~$$BO#rks?gNX^3aW;heDr( z1%_pWm4>Yh+Y)wlpw~d_z;y%v9iAHgdblh6(x4R)hKO+y%OaX1zK&EyCPi+rdRa}@ zNb68*zV#2*dDf-Y)z(JqG3%Wu8D)%$i5lsMs*KtYbtLNN=*Z{^(W|15#CXM&#C#NU z#%8n?+7{SW*y?QuY!6~ttR~hPTN1k}wl(&A?8CUsxT?7QaZd;LADl4wdi?PCKgRzf z{(gc!VOGN13A+-$NVteMD2)UBS(&`8x=OH zCR>y3$i9~|Am^o=wYhzAXXG~KUK$-edfw=Jd1-m8t$7{!+4)=Yy9-ha7CpPe{O;KB zg{pB=e3QF8xoDx|#SIzX?-xFy*|qM|!OV4`u^;zV9?>yQqOCGJ9TW(vDEq5%}EcND}=z?5yyPI#AuUgdRZ1Y}Axp||xkJ-x{X$~^?FsGPqncg!k zGtD&r$rJ;a8co|wYfK-QHko2f876z+EFiEu@Qr|50p$V90yYGC1yl#b0Rh*E`UC_A zcp1x#-bQc0=wYNg3^)v3hWmyq#&mB$W zzjvGKUem7AMG#MSOWJnrQEi9z4ec1+tGXH5hq}4iy;>vTx3qR$kmkJRjJ8NytxeYM z(0r*)aceZkHQkzfny)k)XkDtgt#NGC+|f+Ye5Nt`zv`ducf;?R|7`ydzh%98s!#Nq z*z2^q#GMH6{o41T+S|9)m%cRhOp+h^PW4?({Iu@{Ur(Q%KGi;ted2tBe0%z=1)e+g z+<1>!&kpGq_h&Q9rBldd1^)-ZnbYL&tly!eGwSO1gPDGhP=MaQ(El`y9uLA)OzpHc zJ#YNsH_dHy{<^+P{4P43Z|~5ui)5wmdH6RgV87dsickgCVI62^ejD%5%1!REioE?D zi=M4%Vu8$z$;^{^vEEFL8j61u2hobdh(Rohu@Wsfjsv)kBRGV8XvTgFfgWM-#ua>x za|mH+*oq9wS%(gsW$7#fQMk&IX`>>M$g)w+qN$z$oMd{OVm547Q8~4MD}Xr}vpO63 zW}2IMs&lodbt-!-^i~D$*~VR8>`nA=r62rr4=q&+_Vg)y-5fp$fsLSS=jG!`Y_ZdD zVtwXzxUdUNxQJW0hwpJ8U3iE`c#J3H!kMJ4b+_rw)YPg!(=t6Xy4z>Zvwkd;4P=9u zl|?(BoG>{pZACqbo^6qiN?F*pjXSNUK6eh1H=SLlihGo^BKM_G*<9mKXK+Vm9{7mK z;5jDn(c$0|=7LXd2A|#vKFbrlVm|mQS>W?4!C(Io+(B5)H1OrU!B<`ccUFMc7J{#_ zfxk<>>#M-)gTXhez(2?U|ELB0<1+BZN#HvY!FL@8-~Ao<-c8{9uED|oNkNVs25+kd z?^p1a3p{N(k`;LRAgX^Bja<9)y1~geD9^>j$CZ zL^mPyry&eGA&hkpfocd-FoY!@;`s`QzFQ#rU4{tKI3NbpK!o0f2&cf2PJS0HYALUd6h-60TIWFhgQA&}#%ASVzn-U>O<4{}ljyRH`RD-@ z)e0!ylc8vjLNReDp Date: Sun, 10 May 2026 21:14:20 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Stop-from-phone=20button=20+=20pause=20loca?= =?UTF-8?q?l=20player=20while=20casting=20fix(cast):=20explicit=20RemoteMe?= =?UTF-8?q?diaClient.stop()=20and=20receiver-status=20diagnostics=20fix(ca?= =?UTF-8?q?st):=20keep=20SessionManagerListener=20for=20caster=20lifetime?= =?UTF-8?q?=20+=20await=20stop()=20ack=20refactor(cast):=20kotlin-review?= =?UTF-8?q?=20fixes=20=E2=80=94=20cancellation,=20valueOf=20safety,=20reco?= =?UTF-8?q?mp=20refactor(cast):=20tighten=20types,=20fix=20HLS=20query-str?= =?UTF-8?q?ing=20mime,=20parallelize=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cast/chromecast/ChromecastCaster.kt | 3 +- .../model/preferences/UISharedPreferences.kt | 2 +- .../amethyst/service/cast/CastDevice.kt | 29 ++-- .../amethyst/service/cast/CastRegistry.kt | 66 +++++---- .../amethyst/service/cast/VideoCaster.kt | 6 +- .../amethyst/service/cast/dlna/DlnaCaster.kt | 3 +- .../composable/controls/OverflowMenu.kt | 13 +- .../composable/controls/RenderTopButtons.kt | 50 ++++++- .../ui/cast/CastDevicePickerDialog.kt | 9 +- amethyst/src/main/res/values/strings.xml | 1 + .../cast/chromecast/ChromecastCaster.kt | 132 ++++++++++++++++-- 11 files changed, 246 insertions(+), 68 deletions(-) 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 index e7fbe561ea..6ad94ea73e 100644 --- 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.cast.chromecast import android.content.Context import com.vitorpamplona.amethyst.service.cast.CastDevice +import com.vitorpamplona.amethyst.service.cast.CastDeviceKind import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState import com.vitorpamplona.amethyst.service.cast.VideoCaster @@ -38,7 +39,7 @@ import kotlinx.coroutines.flow.asStateFlow class ChromecastCaster( appContext: Context, ) : VideoCaster { - override val id: String = "chromecast" + override val kind: CastDeviceKind = CastDeviceKind.Chromecast override val devices: StateFlow> = MutableStateFlow>(emptyList()).asStateFlow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 3aef11fa25..b31cd8c606 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -136,7 +136,7 @@ class UiSharedPreferences( preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems, - castProtocol = preferences[UI_CAST_PROTOCOL]?.let { CastProtocolType.valueOf(it) } ?: CastProtocolType.BOTH, + castProtocol = preferences[UI_CAST_PROTOCOL]?.let { runCatching { CastProtocolType.valueOf(it) }.getOrNull() } ?: CastProtocolType.BOTH, ) } catch (e: Exception) { if (e is CancellationException) throw e 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 index a17e208656..089d7d1569 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -32,7 +32,6 @@ data class CastDevice( val id: String, val name: String, val kind: CastDeviceKind, - val casterId: String, ) @Immutable @@ -43,19 +42,21 @@ data class CastRequest( val artworkUri: String? = null, ) -// When the caller didn't supply a mime hint, fall back to a best-guess from the -// URL extension. Critical for HLS: the default Cast receiver crashes loading -// `.m3u8` content sent as `video/mp4` because it tries to demux a playlist as -// MP4 — wiping the TV's Cast service from the network in the process. -fun CastRequest.effectiveMimeType(): String = - mimeType?.takeIf { it.isNotBlank() } - ?: when { - url.endsWith(".m3u8", ignoreCase = true) -> "application/vnd.apple.mpegurl" - url.endsWith(".mpd", ignoreCase = true) -> "application/dash+xml" - url.endsWith(".webm", ignoreCase = true) -> "video/webm" - url.endsWith(".mkv", ignoreCase = true) -> "video/x-matroska" - else -> "video/mp4" - } +// 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() 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 index 776b452dbd..648c97caae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt @@ -25,7 +25,11 @@ import com.vitorpamplona.amethyst.model.CastProtocolType import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster import com.vitorpamplona.amethyst.service.cast.dlna.DlnaCaster import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -74,30 +78,27 @@ class CastRegistry( } Log.d(TAG) { "devices flow update proto=$proto " + - snapshots.mapIndexed { i, l -> "${casters[i].id}=${l.size}" }.joinToString() + - " merged=${merged.size} -> [${merged.joinToString { "${it.casterId}:${it.name}" }}]" + snapshots.mapIndexed { i, l -> "${casters[i].kind}=${l.size}" }.joinToString() + + " merged=${merged.size} -> [${merged.joinToString { "${it.kind}:${it.name}" }}]" } merged - }.stateIn(scope, SharingStarted.Eagerly, emptyList()) + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) val sessionState: StateFlow = combine(casters.map { it.sessionState }) { states -> states.firstOrNull { it !is CastSessionState.Idle } ?: CastSessionState.Idle - }.stateIn(scope, SharingStarted.Eagerly, CastSessionState.Idle) + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), CastSessionState.Idle) private val refCount = AtomicInteger(0) private val active = - ConcurrentHashMap().apply { - casters.forEach { put(it.id, false) } + ConcurrentHashMap().apply { + casters.forEach { put(it.kind, false) } } init { Log.d(TAG) { - "init casters=[${casters.joinToString { it.id }}] initialProtocol=${protocolFlow.value}" + "init casters=[${casters.joinToString { it.kind.name }}] initialProtocol=${protocolFlow.value}" } - // While the picker is open, react to protocol changes by stopping/starting - // the affected casters. Idle case is a no-op because reconcile only runs - // when refCount > 0. scope.launch { protocolFlow.collect { proto -> Log.d(TAG) { "protocolFlow change -> $proto refCount=${refCount.get()}" } @@ -112,8 +113,8 @@ class CastRegistry( ): Boolean = when (proto) { CastProtocolType.BOTH -> true - CastProtocolType.CHROMECAST -> caster.id == "chromecast" - CastProtocolType.DLNA -> caster.id == "dlna" + CastProtocolType.CHROMECAST -> caster.kind == CastDeviceKind.Chromecast + CastProtocolType.DLNA -> caster.kind == CastDeviceKind.Dlna } @Synchronized @@ -121,17 +122,17 @@ class CastRegistry( val proto = protocolFlow.value casters.forEach { caster -> val shouldRun = isCasterEnabled(caster, proto) - val running = active[caster.id] == true + val running = active[caster.kind] == true if (shouldRun && !running) { - Log.d(TAG) { "reconcile START ${caster.id} (proto=$proto)" } + Log.d(TAG) { "reconcile START ${caster.kind} (proto=$proto)" } runCatching { caster.startDiscovery() } - .onFailure { Log.w(TAG, "reconcile START ${caster.id} threw: ${it.message}", it) } - active[caster.id] = true + .onFailure { Log.w(TAG, "reconcile START ${caster.kind} threw: ${it.message}", it) } + active[caster.kind] = true } else if (!shouldRun && running) { - Log.d(TAG) { "reconcile STOP ${caster.id} (proto=$proto)" } + Log.d(TAG) { "reconcile STOP ${caster.kind} (proto=$proto)" } runCatching { caster.stopDiscovery() } - .onFailure { Log.w(TAG, "reconcile STOP ${caster.id} threw: ${it.message}", it) } - active[caster.id] = false + .onFailure { Log.w(TAG, "reconcile STOP ${caster.kind} threw: ${it.message}", it) } + active[caster.kind] = false } } } @@ -140,10 +141,10 @@ class CastRegistry( private fun stopAll() { Log.d(TAG) { "stopAll active=${active.filterValues { it }.keys}" } casters.forEach { caster -> - if (active[caster.id] == true) { + if (active[caster.kind] == true) { runCatching { caster.stopDiscovery() } - .onFailure { Log.w(TAG, "stopAll ${caster.id} threw: ${it.message}", it) } - active[caster.id] = false + .onFailure { Log.w(TAG, "stopAll ${caster.kind} threw: ${it.message}", it) } + active[caster.kind] = false } } } @@ -168,11 +169,11 @@ class CastRegistry( request: CastRequest, ) { Log.d(TAG) { - "cast device=${device.casterId}:${device.name} url=${request.url} mime=${request.mimeType}" + "cast device=${device.kind}:${device.name} url=${request.url} mime=${request.mimeType}" } - val caster = casters.firstOrNull { it.id == device.casterId } + val caster = casters.firstOrNull { it.kind == device.kind } if (caster == null) { - Log.w(TAG, "cast: no caster found for casterId=${device.casterId}") + Log.w(TAG, "cast: no caster found for kind=${device.kind}") return } caster.cast(device, request) @@ -180,6 +181,19 @@ class CastRegistry( suspend fun stopCasting() { Log.d(TAG) { "stopCasting (broadcast to all casters)" } - casters.forEach { runCatching { it.stopCasting() } } + coroutineScope { + casters + .map { caster -> + async { + try { + caster.stopCasting() + } catch (ce: CancellationException) { + throw ce + } catch (t: Throwable) { + Log.w(TAG, "stopCasting ${caster.kind} threw: ${t.message}", t) + } + } + }.awaitAll() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt index a7ad156a8e..343811f326 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt @@ -29,8 +29,8 @@ import kotlinx.coroutines.flow.StateFlow * to give the UI a unified device list and active-session state. */ interface VideoCaster { - /** Stable identifier for diagnostics and merging device IDs across casters. */ - val id: String + /** Which protocol this caster speaks. The registry routes by [CastDevice.kind]. */ + val kind: CastDeviceKind /** Discovered receiver devices; emits a snapshot whenever the set changes. */ val devices: StateFlow> @@ -51,7 +51,7 @@ interface VideoCaster { /** * Hand a video off to [device] and start playback. Updates [sessionState] * to Connecting → Casting (or Error). [device] must be one this caster - * emitted in [devices] — the registry routes by [CastDevice.casterId]. + * emitted in [devices]. */ suspend fun cast( device: CastDevice, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt index ea4f40864d..b712c1330c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt @@ -64,7 +64,7 @@ private val AV_TRANSPORT = UDAServiceType("AVTransport") class DlnaCaster( private val appContext: Context, ) : VideoCaster { - override val id: String = "dlna" + override val kind: CastDeviceKind = CastDeviceKind.Dlna private val devicesFlow = MutableStateFlow>(emptyList()) override val devices: StateFlow> = devicesFlow.asStateFlow() @@ -154,7 +154,6 @@ class DlnaCaster( id = device.identity.udn.identifierString, name = device.details?.friendlyName ?: device.displayString, kind = CastDeviceKind.Dlna, - casterId = id, ) } } 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 54468f21ad..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 @@ -71,6 +72,8 @@ fun OverflowMenuButtonPreview() { onSaveClick = {}, onPipClick = {}, onCastClick = {}, + castIcon = MaterialSymbols.Cast, + castContentDescription = "", ) } } @@ -88,6 +91,8 @@ fun AnimatedOverflowMenuButton( onSaveClick: () -> Unit, onPipClick: () -> Unit, onCastClick: () -> Unit, + castIcon: MaterialSymbol, + castContentDescription: String, modifier: Modifier = Modifier, ) { AnimatedVisibility( @@ -106,6 +111,8 @@ fun AnimatedOverflowMenuButton( onSaveClick = onSaveClick, onPipClick = onPipClick, onCastClick = onCastClick, + castIcon = castIcon, + castContentDescription = castContentDescription, ) } } @@ -121,6 +128,8 @@ fun OverflowMenuButton( onSaveClick: () -> Unit, onPipClick: () -> Unit, onCastClick: () -> Unit, + castIcon: MaterialSymbol, + castContentDescription: String, ) { val menuExpanded = remember { mutableStateOf(false) } @@ -218,8 +227,8 @@ fun OverflowMenuButton( VideoPlayerAction.Cast -> { M3ActionRow( - icon = MaterialSymbols.Cast, - text = stringRes(R.string.cast_to_device), + icon = castIcon, + text = castContentDescription, ) { menuExpanded.value = false onCastClick() 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 5c12f64971..7c14154d60 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,7 @@ 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.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol @@ -56,6 +58,7 @@ 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 @@ -73,6 +76,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 @@ -138,6 +142,22 @@ fun RenderTopButtons( val overflowQualityOpen = remember { mutableStateOf(false) } + // Pause local playback while this video is casting so audio doesn't + // double up; only resume on the transition we caused. + val castSessionStateForLocal by Amethyst.instance.castRegistry.sessionState + .collectAsStateWithLifecycle() + val wasCastingThisVideo = remember { mutableStateOf(false) } + LaunchedEffect(castSessionStateForLocal, mediaData.videoUri) { + val isCastingThis = + (castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri + if (isCastingThis && !wasCastingThisVideo.value) { + player.pause() + } else if (!isCastingThis && wasCastingThisVideo.value) { + player.play() + } + wasCastingThisVideo.value = isCastingThis + } + RenderTopButtons( mediaData = mediaData, hasMultipleQualities = hasMultipleQualities, @@ -200,6 +220,26 @@ 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) @@ -289,9 +329,9 @@ fun RenderTopButtons( VideoPlayerAction.Cast -> { AnimatedTopBarIconButton( controllerVisible = controllerVisible, - symbol = MaterialSymbols.Cast, - contentDescription = stringRes(R.string.cast_to_device), - onClick = { castDialogVisible.value = true }, + symbol = castIcon, + contentDescription = castContentDescription, + onClick = onCastButtonClick, ) } } @@ -308,7 +348,9 @@ fun RenderTopButtons( onShareClick = { shareDialogVisible.value = true }, onSaveClick = saveAction, onPipClick = onPictureInPictureClick, - onCastClick = { castDialogVisible.value = true }, + onCastClick = onCastButtonClick, + castIcon = castIcon, + castContentDescription = castContentDescription, ) } 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 index b9f2ac1f3c..2bb0b30497 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -24,7 +24,6 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -51,11 +50,9 @@ fun CastDevicePickerDialog( onDismiss: () -> Unit, registry: CastRegistry = Amethyst.instance.castRegistry, ) { - Log.d(TAG) { "open url=${request.url} mime=${request.mimeType}" } - LaunchedEffect(registry) { - registry.startDiscovery() - } 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() @@ -93,7 +90,7 @@ fun CastDevicePickerDialog( icon = icon, text = device.name, ) { - Log.d(TAG) { "tap device=${device.casterId}:${device.name}" } + Log.d(TAG) { "tap device=${device.kind}:${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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bbb7aef1a9..57191c6e4d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2559,6 +2559,7 @@ Cast to device + Stop casting Cast to… Searching for devices on your Wi-Fi… Cast protocol 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 index 0283f63b88..96beee47a4 100644 --- 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 @@ -30,9 +30,11 @@ 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 @@ -53,6 +55,7 @@ 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) implementation of [VideoCaster]. @@ -65,7 +68,7 @@ private const val SESSION_START_TIMEOUT_MS = 30_000L class ChromecastCaster( private val appContext: Context, ) : VideoCaster { - override val id: String = "chromecast" + override val kind: CastDeviceKind = CastDeviceKind.Chromecast private val devicesFlow = MutableStateFlow>(emptyList()) override val devices: StateFlow> = devicesFlow.asStateFlow() @@ -81,6 +84,15 @@ class ChromecastCaster( 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 = @@ -110,6 +122,63 @@ class ChromecastCaster( } } + 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) { @@ -121,6 +190,7 @@ class ChromecastCaster( sessionId: String, ) { Log.d(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" } + attachMediaClientCallback(session) pendingSessionStart?.complete(true) pendingSessionStart = null } @@ -148,6 +218,7 @@ class ChromecastCaster( // 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 @@ -166,6 +237,7 @@ class ChromecastCaster( ) { Log.d(TAG) { "session.onResumed wasSuspended=$wasSuspended" } // Resume counts as the session being usable — let cast() proceed. + attachMediaClientCallback(session) pendingSessionStart?.complete(true) pendingSessionStart = null } @@ -215,13 +287,23 @@ class ChromecastCaster( // 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 } + CastContext.getSharedInstance(appContext).also { + castContext = it + attachSessionListener(it) + } } catch (t: Throwable) { Log.w(TAG, "Failed to initialize CastContext: ${t.message}", 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)" } + } + override fun startDiscovery() { Log.d(TAG) { "startDiscovery (already registered? $registered)" } main.post { @@ -241,11 +323,10 @@ class ChromecastCaster( .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) .build() router.addCallback(selector, routerCallback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) - ctx.sessionManager.addSessionManagerListener(sessionListener, CastSession::class.java) mediaRouter = router routeSelector = selector registered = true - Log.d(TAG) { "startDiscovery: registered router callback + session listener" } + Log.d(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" } updateRoutes(router) } } @@ -255,11 +336,13 @@ class ChromecastCaster( main.post { if (!registered) return@post mediaRouter?.removeCallback(routerCallback) - castContext?.sessionManager?.removeSessionManagerListener(sessionListener, CastSession::class.java) + // 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: unregistered" } + Log.d(TAG) { "stopDiscovery: router callback removed (sessionListener kept)" } } } @@ -277,7 +360,6 @@ class ChromecastCaster( id = route.id, name = route.name, kind = CastDeviceKind.Chromecast, - casterId = id, ) } Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" } @@ -314,6 +396,10 @@ class ChromecastCaster( "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}" } @@ -396,10 +482,38 @@ class ChromecastCaster( } override suspend fun stopCasting() { - Log.d(TAG) { "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.message}", 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 { - castContext?.sessionManager?.endCurrentSession(true) + // 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.message}", t) } From 91aa72ae1e550f1bb810d9655afcdf993a943e8b Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 11 May 2026 08:34:27 +0200 Subject: [PATCH 3/4] Rip DLNA code paths, collapse to Chromecast-only - chore(cast): drop protocol-toggle settings, strings, perms, deps - gate cast on play flavor via BuildConfig.IS_CASTING_AVAILABLE - fix(cast): keep local player paused across recompose; disable transport while casting --- amethyst/build.gradle | 16 +- amethyst/proguard-rules.pro | 7 - .../cast/chromecast/ChromecastCaster.kt | 23 +- amethyst/src/main/AndroidManifest.xml | 5 - .../com/vitorpamplona/amethyst/AppModules.kt | 8 +- .../amethyst/model/UiSettings.kt | 18 - .../amethyst/model/UiSettingsFlow.kt | 9 - .../model/preferences/UISharedPreferences.kt | 4 - .../amethyst/service/cast/CastDevice.kt | 6 - .../amethyst/service/cast/CastRegistry.kt | 150 +------ .../amethyst/service/cast/VideoCaster.kt | 63 --- .../amethyst/service/cast/dlna/DlnaCaster.kt | 379 ------------------ .../playback/composable/RenderVideoPlayer.kt | 1 + .../composable/controls/PlayPauseButton.kt | 5 +- .../controls/RenderCenterButtons.kt | 34 +- .../composable/controls/RenderTopButtons.kt | 71 +++- .../composable/controls/SkipButton.kt | 9 +- .../ui/cast/CastDevicePickerDialog.kt | 10 +- .../loggedIn/settings/AppSettingsScreen.kt | 24 -- .../settings/VideoPlayerSettingsScreen.kt | 9 +- amethyst/src/main/res/values/strings.xml | 7 +- .../cast/chromecast/ChromecastCaster.kt | 21 +- gradle/libs.versions.toml | 12 - 23 files changed, 147 insertions(+), 744 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 4549d5a047..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,20 +381,6 @@ dependencies { //PushNotifications(FDroid) fdroidImplementation libs.unifiedpush - // DLNA / UPnP discovery and AVTransport — used by the LAN cast feature in - // both flavors. jUPnP is the maintained fork of Cling. - implementation libs.jupnp.core - implementation libs.jupnp.android - implementation libs.jupnp.support - // jUPnP-android's AndroidUpnpServiceConfiguration.createStreamServer wires - // in JettyServletContainer (needs jetty-server + jetty-servlet) AND - // createStreamClient wires in JettyStreamClientImpl (needs jetty-client) — - // without all three jUPnP throws NoClassDefFoundError on startup() and - // DLNA discovery never starts. - implementation libs.jetty.server - implementation libs.jetty.servlet - implementation libs.jetty.client - // 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. diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index e55a4c2d24..6e21a524cd 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -70,10 +70,3 @@ (); } -# jUPnP ships optional Jetty + OSGi transport adapters that Android doesn't -# use (we run jupnp-android's Servlet-less transport). Tell R8 to ignore the -# dangling references rather than fail the minify step. --dontwarn org.eclipse.jetty.** --dontwarn org.osgi.** --dontwarn javax.servlet.** --dontwarn org.jupnp.transport.impl.jetty.** 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 index 6ad94ea73e..61bdddc0af 100644 --- 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 @@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.service.cast.chromecast import android.content.Context import com.vitorpamplona.amethyst.service.cast.CastDevice -import com.vitorpamplona.amethyst.service.cast.CastDeviceKind import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState -import com.vitorpamplona.amethyst.service.cast.VideoCaster import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,26 +31,27 @@ 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 skips Chromecast paths at runtime. + * 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, -) : VideoCaster { - override val kind: CastDeviceKind = CastDeviceKind.Chromecast +) { + val devices: StateFlow> = MutableStateFlow>(emptyList()).asStateFlow() - override val devices: StateFlow> = MutableStateFlow>(emptyList()).asStateFlow() + val sessionState: StateFlow = MutableStateFlow(CastSessionState.Idle).asStateFlow() - override val sessionState: StateFlow = MutableStateFlow(CastSessionState.Idle).asStateFlow() + fun startDiscovery() = Unit - override fun startDiscovery() = Unit + fun stopDiscovery() = Unit - override fun stopDiscovery() = Unit - - override suspend fun cast( + suspend fun cast( device: CastDevice, request: CastRequest, ) = Unit - override suspend fun stopCasting() = Unit + suspend fun stopCasting() = Unit } diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 9c914026f2..f49058bee7 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -42,11 +42,6 @@ (otherwise the wrapper waits ~30s for QUIC PTO before reconnecting). --> - - - - diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cc358f57b0..466779ad6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -493,12 +493,12 @@ class AppModules( Nip95CacheFactory.new(appContext) } - // LAN cast registry — aggregates Chromecast (play flavor only) and DLNA - // discovery into a single device list. Discovery starts only when the - // picker dialog opens; idle by default to keep multicast traffic off. + // 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, applicationIOScope, uiPrefs.value.castProtocol) + CastRegistry(appContext) } // local video cache with disk + memory diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 2b043bb775..e1ebf5fa8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -47,7 +47,6 @@ data class UiSettings( val showHomeNewThreadsTab: Boolean = true, val showHomeConversationsTab: Boolean = true, val showHomeEverythingTab: Boolean = false, - val castProtocol: CastProtocolType = CastProtocolType.BOTH, ) enum class ThemeType( @@ -148,23 +147,6 @@ fun parseBooleanType(screenCode: Int): BooleanType = else -> BooleanType.ALWAYS } -enum class CastProtocolType( - val screenCode: Int, - val resourceId: Int, -) { - BOTH(0, R.string.cast_protocol_both), - CHROMECAST(1, R.string.cast_protocol_chromecast), - DLNA(2, R.string.cast_protocol_dlna), -} - -fun parseCastProtocolType(screenCode: Int): CastProtocolType = - when (screenCode) { - CastProtocolType.BOTH.screenCode -> CastProtocolType.BOTH - CastProtocolType.CHROMECAST.screenCode -> CastProtocolType.CHROMECAST - CastProtocolType.DLNA.screenCode -> CastProtocolType.DLNA - else -> CastProtocolType.BOTH - } - enum class WarningType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index ef96a8b970..8a57956362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -47,7 +47,6 @@ class UiSettingsFlow( val showHomeNewThreadsTab: MutableStateFlow = MutableStateFlow(true), val showHomeConversationsTab: MutableStateFlow = MutableStateFlow(true), val showHomeEverythingTab: MutableStateFlow = MutableStateFlow(false), - val castProtocol: MutableStateFlow = MutableStateFlow(CastProtocolType.BOTH), ) { val listOfFlows: List> = listOf>( @@ -69,7 +68,6 @@ class UiSettingsFlow( showHomeNewThreadsTab, showHomeConversationsTab, showHomeEverythingTab, - castProtocol, ) // emits at every change in any of the propertyes. @@ -95,7 +93,6 @@ class UiSettingsFlow( flows[15] as Boolean, flows[16] as Boolean, flows[17] as Boolean, - flows[18] as CastProtocolType, ) } @@ -119,7 +116,6 @@ class UiSettingsFlow( showHomeNewThreadsTab.value, showHomeConversationsTab.value, showHomeEverythingTab.value, - castProtocol.value, ) fun update(torSettings: UiSettings): Boolean { @@ -197,10 +193,6 @@ class UiSettingsFlow( showHomeEverythingTab.tryEmit(torSettings.showHomeEverythingTab) any = true } - if (castProtocol.value != torSettings.castProtocol) { - castProtocol.tryEmit(torSettings.castProtocol) - any = true - } return any } @@ -238,7 +230,6 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.showHomeNewThreadsTab), MutableStateFlow(uiSettings.showHomeConversationsTab), MutableStateFlow(uiSettings.showHomeEverythingTab), - MutableStateFlow(uiSettings.castProtocol), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index b31cd8c606..28be3d53e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -32,7 +32,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.model.CastProtocolType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.ProfileGalleryType @@ -109,7 +108,6 @@ class UiSharedPreferences( val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items") - val UI_CAST_PROTOCOL = stringPreferencesKey("ui.cast_protocol") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -136,7 +134,6 @@ class UiSharedPreferences( preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems, - castProtocol = preferences[UI_CAST_PROTOCOL]?.let { runCatching { CastProtocolType.valueOf(it) }.getOrNull() } ?: CastProtocolType.BOTH, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -176,7 +173,6 @@ class UiSharedPreferences( preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name } - preferences[UI_CAST_PROTOCOL] = sharedSettings.castProtocol.name } } catch (e: Exception) { if (e is CancellationException) throw e 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 index 089d7d1569..670d34d339 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -22,16 +22,10 @@ package com.vitorpamplona.amethyst.service.cast import androidx.compose.runtime.Immutable -enum class CastDeviceKind { - Chromecast, - Dlna, -} - @Immutable data class CastDevice( val id: String, val name: String, - val kind: CastDeviceKind, ) @Immutable 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 index 648c97caae..3c15c243fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt @@ -21,138 +21,36 @@ package com.vitorpamplona.amethyst.service.cast import android.content.Context -import com.vitorpamplona.amethyst.model.CastProtocolType import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster -import com.vitorpamplona.amethyst.service.cast.dlna.DlnaCaster import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger private const val TAG = "CastRegistry" /** - * Aggregates every [VideoCaster] implementation available on this build into - * a single device list and a single active-session view, so the cast UI - * doesn't care whether a device speaks Chromecast or DLNA. + * 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. * - * Discovery is started/stopped explicitly by callers (typically the picker - * dialog's DisposableEffect) so that SSDP multicast traffic + the - * MulticastLock are only paid for while the user is choosing. - * - * The registry honours [protocolFlow]: when the user has narrowed cast to a - * single protocol, the disabled caster never starts discovery and its devices - * are filtered out of [devices]. Switching protocols mid-session leaves any - * active cast running — only future discovery is affected. + * 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, - scope: CoroutineScope, - private val protocolFlow: StateFlow = - MutableStateFlow(CastProtocolType.BOTH), ) { - private val casters: List = - listOf( - ChromecastCaster(appContext), - DlnaCaster(appContext), - ) + private val caster = ChromecastCaster(appContext) - val devices: StateFlow> = - combine( - combine(casters.map { it.devices }) { snapshots -> snapshots.toList() }, - protocolFlow, - ) { snapshots, proto -> - val merged = - snapshots.flatMapIndexed { index, list -> - if (isCasterEnabled(casters[index], proto)) list else emptyList() - } - Log.d(TAG) { - "devices flow update proto=$proto " + - snapshots.mapIndexed { i, l -> "${casters[i].kind}=${l.size}" }.joinToString() + - " merged=${merged.size} -> [${merged.joinToString { "${it.kind}:${it.name}" }}]" - } - merged - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - val sessionState: StateFlow = - combine(casters.map { it.sessionState }) { states -> - states.firstOrNull { it !is CastSessionState.Idle } ?: CastSessionState.Idle - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), CastSessionState.Idle) + val devices: StateFlow> = caster.devices + val sessionState: StateFlow = caster.sessionState private val refCount = AtomicInteger(0) - private val active = - ConcurrentHashMap().apply { - casters.forEach { put(it.kind, false) } - } - - init { - Log.d(TAG) { - "init casters=[${casters.joinToString { it.kind.name }}] initialProtocol=${protocolFlow.value}" - } - scope.launch { - protocolFlow.collect { proto -> - Log.d(TAG) { "protocolFlow change -> $proto refCount=${refCount.get()}" } - if (refCount.get() > 0) reconcile() - } - } - } - - private fun isCasterEnabled( - caster: VideoCaster, - proto: CastProtocolType, - ): Boolean = - when (proto) { - CastProtocolType.BOTH -> true - CastProtocolType.CHROMECAST -> caster.kind == CastDeviceKind.Chromecast - CastProtocolType.DLNA -> caster.kind == CastDeviceKind.Dlna - } - - @Synchronized - private fun reconcile() { - val proto = protocolFlow.value - casters.forEach { caster -> - val shouldRun = isCasterEnabled(caster, proto) - val running = active[caster.kind] == true - if (shouldRun && !running) { - Log.d(TAG) { "reconcile START ${caster.kind} (proto=$proto)" } - runCatching { caster.startDiscovery() } - .onFailure { Log.w(TAG, "reconcile START ${caster.kind} threw: ${it.message}", it) } - active[caster.kind] = true - } else if (!shouldRun && running) { - Log.d(TAG) { "reconcile STOP ${caster.kind} (proto=$proto)" } - runCatching { caster.stopDiscovery() } - .onFailure { Log.w(TAG, "reconcile STOP ${caster.kind} threw: ${it.message}", it) } - active[caster.kind] = false - } - } - } - - @Synchronized - private fun stopAll() { - Log.d(TAG) { "stopAll active=${active.filterValues { it }.keys}" } - casters.forEach { caster -> - if (active[caster.kind] == true) { - runCatching { caster.stopDiscovery() } - .onFailure { Log.w(TAG, "stopAll ${caster.kind} threw: ${it.message}", it) } - active[caster.kind] = false - } - } - } fun startDiscovery() { val before = refCount.getAndIncrement() Log.d(TAG) { "startDiscovery refCount $before -> ${before + 1}" } - if (before == 0) reconcile() + if (before == 0) caster.startDiscovery() } fun stopDiscovery() { @@ -160,7 +58,7 @@ class CastRegistry( Log.d(TAG) { "stopDiscovery refCount -> $after" } if (after <= 0) { refCount.set(0) - stopAll() + caster.stopDiscovery() } } @@ -168,32 +66,12 @@ class CastRegistry( device: CastDevice, request: CastRequest, ) { - Log.d(TAG) { - "cast device=${device.kind}:${device.name} url=${request.url} mime=${request.mimeType}" - } - val caster = casters.firstOrNull { it.kind == device.kind } - if (caster == null) { - Log.w(TAG, "cast: no caster found for kind=${device.kind}") - return - } + Log.d(TAG) { "cast device=${device.name} url=${request.url} mime=${request.mimeType}" } caster.cast(device, request) } suspend fun stopCasting() { - Log.d(TAG) { "stopCasting (broadcast to all casters)" } - coroutineScope { - casters - .map { caster -> - async { - try { - caster.stopCasting() - } catch (ce: CancellationException) { - throw ce - } catch (t: Throwable) { - Log.w(TAG, "stopCasting ${caster.kind} threw: ${t.message}", t) - } - } - }.awaitAll() - } + Log.d(TAG) { "stopCasting" } + caster.stopCasting() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt deleted file mode 100644 index 343811f326..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 kotlinx.coroutines.flow.StateFlow - -/** - * Common contract for any LAN cast protocol that Amethyst can drive — currently - * Chromecast (Google Cast SDK, play flavor only) and DLNA / UPnP AVTransport - * (jUPnP, both flavors). The registry layer aggregates one or more of these - * to give the UI a unified device list and active-session state. - */ -interface VideoCaster { - /** Which protocol this caster speaks. The registry routes by [CastDevice.kind]. */ - val kind: CastDeviceKind - - /** Discovered receiver devices; emits a snapshot whenever the set changes. */ - val devices: StateFlow> - - /** Status of the active casting session owned by this caster. */ - val sessionState: StateFlow - - /** - * Called by the registry while at least one observer wants device updates - * (typically: the picker dialog is open, or a session is active). - * Implementations should be idempotent. - */ - fun startDiscovery() - - /** Counterpart to [startDiscovery]. Idempotent; no-op when not running. */ - fun stopDiscovery() - - /** - * Hand a video off to [device] and start playback. Updates [sessionState] - * to Connecting → Casting (or Error). [device] must be one this caster - * emitted in [devices]. - */ - suspend fun cast( - device: CastDevice, - request: CastRequest, - ) - - /** End the active session if any. Idempotent. */ - suspend fun stopCasting() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt deleted file mode 100644 index b712c1330c..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt +++ /dev/null @@ -1,379 +0,0 @@ -/* - * 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.dlna - -import android.content.Context -import android.net.wifi.WifiManager -import com.vitorpamplona.amethyst.service.cast.CastDevice -import com.vitorpamplona.amethyst.service.cast.CastDeviceKind -import com.vitorpamplona.amethyst.service.cast.CastRequest -import com.vitorpamplona.amethyst.service.cast.CastSessionState -import com.vitorpamplona.amethyst.service.cast.VideoCaster -import com.vitorpamplona.amethyst.service.cast.effectiveMimeType -import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.jupnp.UpnpService -import org.jupnp.UpnpServiceImpl -import org.jupnp.android.AndroidUpnpServiceConfiguration -import org.jupnp.model.action.ActionInvocation -import org.jupnp.model.message.UpnpResponse -import org.jupnp.model.message.header.STAllHeader -import org.jupnp.model.meta.LocalDevice -import org.jupnp.model.meta.RemoteDevice -import org.jupnp.model.types.UDADeviceType -import org.jupnp.model.types.UDAServiceType -import org.jupnp.model.types.UDN -import org.jupnp.registry.Registry -import org.jupnp.registry.RegistryListener -import org.jupnp.support.avtransport.callback.Play -import org.jupnp.support.avtransport.callback.SetAVTransportURI -import org.jupnp.support.avtransport.callback.Stop -import org.jupnp.support.contentdirectory.DIDLParser -import org.jupnp.support.model.DIDLContent -import org.jupnp.support.model.ProtocolInfo -import org.jupnp.support.model.Res -import org.jupnp.support.model.item.VideoItem -import java.util.concurrent.ConcurrentHashMap - -private const val TAG = "DlnaCaster" - -private val MEDIA_RENDERER = UDADeviceType("MediaRenderer") -private val AV_TRANSPORT = UDAServiceType("AVTransport") - -class DlnaCaster( - private val appContext: Context, -) : VideoCaster { - override val kind: CastDeviceKind = CastDeviceKind.Dlna - - private val devicesFlow = MutableStateFlow>(emptyList()) - override val devices: StateFlow> = devicesFlow.asStateFlow() - - private val sessionFlow = MutableStateFlow(CastSessionState.Idle) - override val sessionState: StateFlow = sessionFlow.asStateFlow() - - private val knownDevices = ConcurrentHashMap() - private var multicastLock: WifiManager.MulticastLock? = null - - @Volatile - private var upnpService: UpnpService? = null - - private val listener = - object : RegistryListener { - override fun remoteDeviceDiscoveryStarted( - registry: Registry, - device: RemoteDevice, - ) { - Log.d(TAG) { "discoveryStarted udn=${device.identity.udn} type=${device.type}" } - } - - override fun remoteDeviceDiscoveryFailed( - registry: Registry, - device: RemoteDevice, - ex: Exception?, - ) { - Log.w(TAG, "discoveryFailed udn=${device.identity.udn} type=${device.type} ex=${ex?.message}", ex) - } - - override fun remoteDeviceAdded( - registry: Registry, - device: RemoteDevice, - ) { - val isRenderer = device.type.implementsVersion(MEDIA_RENDERER) - Log.d(TAG) { - "remoteDeviceAdded udn=${device.identity.udn} type=${device.type} renderer=$isRenderer name=${device.details?.friendlyName}" - } - if (isRenderer) addDevice(device) - } - - override fun remoteDeviceUpdated( - registry: Registry, - device: RemoteDevice, - ) { - if (device.type.implementsVersion(MEDIA_RENDERER)) addDevice(device) - } - - override fun remoteDeviceRemoved( - registry: Registry, - device: RemoteDevice, - ) { - Log.d(TAG) { "remoteDeviceRemoved udn=${device.identity.udn}" } - removeDevice(device.identity.udn.identifierString) - } - - override fun localDeviceAdded( - registry: Registry, - device: LocalDevice, - ) = Unit - - override fun localDeviceRemoved( - registry: Registry, - device: LocalDevice, - ) = Unit - - override fun beforeShutdown(registry: Registry) = Unit - - override fun afterShutdown() = Unit - } - - private fun addDevice(device: RemoteDevice) { - val udn = device.identity.udn.identifierString - knownDevices[udn] = device - publish() - } - - private fun removeDevice(udn: String) { - knownDevices.remove(udn) - publish() - } - - private fun publish() { - devicesFlow.value = - knownDevices.values.map { device -> - CastDevice( - id = device.identity.udn.identifierString, - name = device.details?.friendlyName ?: device.displayString, - kind = CastDeviceKind.Dlna, - ) - } - } - - @Synchronized - override fun startDiscovery() { - Log.d(TAG) { "startDiscovery (already running? ${upnpService != null})" } - if (upnpService != null) return - try { - acquireMulticastLock() - val service = UpnpServiceImpl(AndroidUpnpServiceConfiguration()) - service.startup() - service.registry.addListener(listener) - // Republish anything already in the registry (covers reuse). - val preexisting = - service.registry.remoteDevices - .filter { it.type.implementsVersion(MEDIA_RENDERER) } - Log.d(TAG) { "startDiscovery: preexisting renderers=${preexisting.size}" } - preexisting.forEach { addDevice(it) } - service.controlPoint.search(STAllHeader()) - upnpService = service - Log.d(TAG) { "startDiscovery: jUPnP started, SSDP M-SEARCH sent (multicastLock held=${multicastLock?.isHeld})" } - } catch (t: Throwable) { - Log.w(TAG, "startDiscovery failed: ${t.message}", t) - releaseMulticastLock() - } - } - - @Synchronized - override fun stopDiscovery() { - Log.d(TAG) { "stopDiscovery (running? ${upnpService != null})" } - val service = upnpService ?: return - try { - service.registry.removeListener(listener) - service.shutdown() - } catch (t: Throwable) { - Log.w(TAG, "stopDiscovery failed: ${t.message}", t) - } - upnpService = null - knownDevices.clear() - publish() - releaseMulticastLock() - Log.d(TAG) { "stopDiscovery: jUPnP stopped, multicast lock released" } - } - - private fun acquireMulticastLock() { - if (multicastLock != null) return - try { - val wifi = appContext.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager - if (wifi == null) { - Log.w(TAG, "acquireMulticastLock: WifiManager unavailable") - return - } - val lock = wifi.createMulticastLock("amethyst-dlna-cast") - lock.setReferenceCounted(false) - lock.acquire() - multicastLock = lock - Log.d(TAG) { "acquireMulticastLock: held=${lock.isHeld}" } - } catch (t: Throwable) { - Log.w(TAG, "acquireMulticastLock failed: ${t.message}", t) - } - } - - private fun releaseMulticastLock() { - try { - multicastLock?.takeIf { it.isHeld }?.release() - } catch (t: Throwable) { - Log.w(TAG, "releaseMulticastLock failed: ${t.message}", t) - } - multicastLock = null - } - - override suspend fun cast( - device: CastDevice, - request: CastRequest, - ) { - Log.d(TAG) { "cast device=${device.name} udn=${device.id} url=${request.url}" } - val service = upnpService - if (service == null) { - Log.w(TAG, "cast: jUPnP service not running") - sessionFlow.value = CastSessionState.Error(device, "DLNA service not running") - return - } - val remoteDevice = knownDevices[device.id] ?: service.registry.getRemoteDevice(UDN.valueOf(device.id), true) - if (remoteDevice == null) { - Log.w(TAG, "cast: device ${device.id} not in registry") - sessionFlow.value = CastSessionState.Error(device, "Device went offline") - return - } - val avTransport = remoteDevice.findService(AV_TRANSPORT) - if (avTransport == null) { - Log.w(TAG, "cast: device ${device.id} has no AVTransport service") - sessionFlow.value = CastSessionState.Error(device, "Device does not advertise AVTransport") - return - } - - sessionFlow.value = CastSessionState.Connecting(device) - - val metadata = buildDidlMetadata(request) - Log.d(TAG) { "cast: SetAVTransportURI -> ${request.url} (didl=${metadata.length}b)" } - - val setUriDone = CompletableDeferred() - service.controlPoint.execute( - object : SetAVTransportURI(avTransport, request.url, metadata) { - override fun success(invocation: ActionInvocation<*>?) { - setUriDone.complete(true) - } - - override fun failure( - invocation: ActionInvocation<*>?, - operation: UpnpResponse?, - defaultMsg: String?, - ) { - Log.w(TAG, "SetAVTransportURI failed: $defaultMsg") - setUriDone.complete(false) - } - }, - ) - - val uriOk = setUriDone.await() - if (!uriOk) { - sessionFlow.value = CastSessionState.Error(device, "Receiver rejected the media URL") - return - } - - val playDone = CompletableDeferred() - service.controlPoint.execute( - object : Play(avTransport) { - override fun success(invocation: ActionInvocation<*>?) { - playDone.complete(true) - } - - override fun failure( - invocation: ActionInvocation<*>?, - operation: UpnpResponse?, - defaultMsg: String?, - ) { - Log.w(TAG, "Play failed: $defaultMsg") - playDone.complete(false) - } - }, - ) - - if (playDone.await()) { - Log.d(TAG) { "cast: Play succeeded" } - sessionFlow.value = CastSessionState.Casting(device, request) - } else { - Log.w(TAG, "cast: Play action rejected by receiver") - sessionFlow.value = CastSessionState.Error(device, "Receiver rejected Play action") - } - } - - override suspend fun stopCasting() { - Log.d(TAG) { "stopCasting (running? ${upnpService != null}, state=${sessionFlow.value::class.simpleName})" } - val service = - upnpService ?: run { - sessionFlow.value = CastSessionState.Idle - return - } - val current = sessionFlow.value - val device = - when (current) { - is CastSessionState.Casting -> { - current.device - } - - is CastSessionState.Connecting -> { - current.device - } - - else -> { - sessionFlow.value = CastSessionState.Idle - return - } - } - val remoteDevice = knownDevices[device.id] - val avTransport = remoteDevice?.findService(AV_TRANSPORT) - if (avTransport != null) { - val done = CompletableDeferred() - service.controlPoint.execute( - object : Stop(avTransport) { - override fun success(invocation: ActionInvocation<*>?) { - done.complete(Unit) - } - - override fun failure( - invocation: ActionInvocation<*>?, - operation: UpnpResponse?, - defaultMsg: String?, - ) { - done.complete(Unit) - } - }, - ) - done.await() - } - sessionFlow.value = CastSessionState.Idle - } - - private fun buildDidlMetadata(request: CastRequest): String = - try { - val mime = request.effectiveMimeType() - val res = Res(ProtocolInfo("http-get:*:$mime:*"), null as Long?, request.url) - val item = - VideoItem( - // id = - "amethyst-cast-1", - // parentID = - "0", - // title = - request.title ?: "Amethyst", - // creator = - "Amethyst", - res, - ) - val didl = DIDLContent() - didl.addItem(item) - DIDLParser().generate(didl) - } catch (t: Throwable) { - Log.w(TAG, "Could not build DIDL metadata, sending empty: ${t.message}", t) - "" - } -} 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/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 7c14154d60..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 @@ -50,6 +50,7 @@ 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 @@ -143,19 +144,39 @@ fun RenderTopButtons( val overflowQualityOpen = remember { mutableStateOf(false) } // Pause local playback while this video is casting so audio doesn't - // double up; only resume on the transition we caused. + // 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 wasCastingThisVideo = remember { mutableStateOf(false) } - LaunchedEffect(castSessionStateForLocal, mediaData.videoUri) { - val isCastingThis = - (castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri - if (isCastingThis && !wasCastingThisVideo.value) { + val isCastingThisVideo = + (castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri + DisposableEffect(player, isCastingThisVideo) { + if (isCastingThisVideo) { player.pause() - } else if (!isCastingThis && wasCastingThisVideo.value) { + 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() } - wasCastingThisVideo.value = isCastingThis + previousCasting.value = isCastingThisVideo } RenderTopButtons( @@ -247,13 +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.Cast -> mediaData.videoUri.startsWith("http", ignoreCase = true) + 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 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 index 2bb0b30497..88f567d1aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -31,7 +31,6 @@ 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.CastDeviceKind import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -81,16 +80,11 @@ fun CastDevicePickerDialog( } else { M3ActionSection { devices.forEach { device -> - val icon = - when (device.kind) { - CastDeviceKind.Chromecast -> MaterialSymbols.Cast - CastDeviceKind.Dlna -> MaterialSymbols.CastConnected - } M3ActionRow( - icon = icon, + icon = MaterialSymbols.Cast, text = device.name, ) { - Log.d(TAG) { "tap device=${device.kind}:${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 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 805e1d80db..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 @@ -56,14 +56,12 @@ 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.CastProtocolType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.parseBooleanType -import com.vitorpamplona.amethyst.model.parseCastProtocolType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType @@ -132,7 +130,6 @@ fun SettingsScreen( ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) if (BuildConfig.FLAVOR == "play") { - CastProtocolChoice(sharedPrefs) } ShowUrlPreviewChoice(sharedPrefs) ShowProfilePictureChoice(sharedPrefs) @@ -286,27 +283,6 @@ fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) { } } -@Composable -fun CastProtocolChoice(sharedPrefs: UiSettingsFlow) { - val castProtocolOptions = - persistentListOf( - TitleExplainer(stringRes(CastProtocolType.BOTH.resourceId)), - TitleExplainer(stringRes(CastProtocolType.CHROMECAST.resourceId)), - TitleExplainer(stringRes(CastProtocolType.DLNA.resourceId)), - ) - - val castProtocolIndex by sharedPrefs.castProtocol.collectAsState() - - SettingsRow( - R.string.cast_protocol_setting_title, - R.string.cast_protocol_setting_description, - castProtocolOptions, - castProtocolIndex.screenCode, - ) { - sharedPrefs.castProtocol.tryEmit(parseCastProtocolType(it)) - } -} - @Composable fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) { val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState() 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 5b8e921919..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() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 57191c6e4d..32ef42f027 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2557,16 +2557,11 @@ Playback Auto - + Cast to device Stop casting Cast to… Searching for devices on your Wi-Fi… - Cast protocol - Which devices to scan for when casting videos on your Wi-Fi - Chromecast & UPnP - Chromecast only - UPnP / DLNA only HLS Upload 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 index 96beee47a4..e27b75bfda 100644 --- 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 @@ -39,10 +39,8 @@ 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.CastDeviceKind import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState -import com.vitorpamplona.amethyst.service.cast.VideoCaster import com.vitorpamplona.amethyst.service.cast.effectiveMimeType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CompletableDeferred @@ -58,7 +56,7 @@ private const val SESSION_START_TIMEOUT_MS = 30_000L private const val STOP_AWAIT_TIMEOUT_MS = 5_000L /** - * Google Cast (Chromecast) implementation of [VideoCaster]. + * 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 @@ -67,14 +65,12 @@ private const val STOP_AWAIT_TIMEOUT_MS = 5_000L */ class ChromecastCaster( private val appContext: Context, -) : VideoCaster { - override val kind: CastDeviceKind = CastDeviceKind.Chromecast - +) { private val devicesFlow = MutableStateFlow>(emptyList()) - override val devices: StateFlow> = devicesFlow.asStateFlow() + val devices: StateFlow> = devicesFlow.asStateFlow() private val sessionFlow = MutableStateFlow(CastSessionState.Idle) - override val sessionState: StateFlow = sessionFlow.asStateFlow() + val sessionState: StateFlow = sessionFlow.asStateFlow() private val main = Handler(Looper.getMainLooper()) private var mediaRouter: MediaRouter? = null @@ -304,7 +300,7 @@ class ChromecastCaster( Log.d(TAG) { "sessionListener attached (caster lifetime)" } } - override fun startDiscovery() { + fun startDiscovery() { Log.d(TAG) { "startDiscovery (already registered? $registered)" } main.post { if (registered) { @@ -331,7 +327,7 @@ class ChromecastCaster( } } - override fun stopDiscovery() { + fun stopDiscovery() { Log.d(TAG) { "stopDiscovery (registered=$registered)" } main.post { if (!registered) return@post @@ -359,14 +355,13 @@ class ChromecastCaster( CastDevice( id = route.id, name = route.name, - kind = CastDeviceKind.Chromecast, ) } Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" } devicesFlow.value = list } - override suspend fun cast( + suspend fun cast( device: CastDevice, request: CastRequest, ) { @@ -481,7 +476,7 @@ class ChromecastCaster( .build() } - override suspend fun stopCasting() { + 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). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 105d115d8e..8a38983219 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,12 +65,6 @@ tarsosdsp = "2.5" translate = "17.0.3" jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" -jupnp = "3.0.4" -# jUPnP 3.0.x's AndroidUpnpServiceConfiguration.createStreamServer wires in -# JettyServletContainer at runtime — without these jUPnP throws -# NoClassDefFoundError on startup() and DLNA discovery never runs. Stay on the -# 9.4 line because jUPnP 3.0.x targets javax.servlet (Jetty 10+ uses jakarta). -jetty = "9.4.57.v20241219" playServicesCast = "22.1.0" vico-charts-compose = "3.1.0" zelory = "3.0.1" @@ -195,12 +189,6 @@ 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" } -jupnp-android = { group = "org.jupnp", name = "org.jupnp.android", version.ref = "jupnp" } -jupnp-core = { group = "org.jupnp", name = "org.jupnp", version.ref = "jupnp" } -jupnp-support = { group = "org.jupnp", name = "org.jupnp.support", version.ref = "jupnp" } -jetty-server = { group = "org.eclipse.jetty", name = "jetty-server", version.ref = "jetty" } -jetty-servlet = { group = "org.eclipse.jetty", name = "jetty-servlet", version.ref = "jetty" } -jetty-client = { group = "org.eclipse.jetty", name = "jetty-client", version.ref = "jetty" } 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" } From 9d2f10646644361754af9dc4cafe83883bba870a Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 11 May 2026 16:17:59 +0200 Subject: [PATCH 4/4] Lambda-convert eager Log.w + drop redundant ${t.message} --- .../cast/chromecast/ChromecastCaster.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) 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 index e27b75bfda..9529ef6418 100644 --- 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 @@ -131,7 +131,7 @@ class ChromecastCaster( } override fun onMediaError(mediaError: com.google.android.gms.cast.MediaError) { - Log.w(TAG, "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}") + Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" } } } @@ -195,7 +195,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.w(TAG, "session.onStartFailed error=$error") + Log.w(TAG) { "session.onStartFailed error=$error" } pendingSessionStart?.complete(false) pendingSessionStart = null sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)") @@ -242,7 +242,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.w(TAG, "session.onResumeFailed error=$error") + Log.w(TAG) { "session.onResumeFailed error=$error" } pendingSessionStart?.complete(false) pendingSessionStart = null } @@ -288,7 +288,7 @@ class ChromecastCaster( attachSessionListener(it) } } catch (t: Throwable) { - Log.w(TAG, "Failed to initialize CastContext: ${t.message}", t) + Log.w(TAG, "Failed to initialize CastContext", t) null } } @@ -377,7 +377,7 @@ class ChromecastCaster( 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?") + Log.w(TAG) { "cast: route ${device.id} not in current set; offline?" } sessionFlow.value = CastSessionState.Error(device, "Device went offline") return } @@ -404,7 +404,7 @@ class ChromecastCaster( pending.complete(true) } } catch (t: Throwable) { - Log.w(TAG, "cast: selectRoute threw: ${t.message}", t) + Log.w(TAG, "cast: selectRoute threw", t) pending.complete(false) } // Defence in depth: if a callback is somehow missed (SDK bug, @@ -414,7 +414,7 @@ class ChromecastCaster( // 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") + Log.w(TAG) { "cast: session start timed out after ${SESSION_START_TIMEOUT_MS}ms" } pendingSessionStart = null } outcome ?: false @@ -442,7 +442,7 @@ class ChromecastCaster( Log.d(TAG) { "cast: load() submitted (status=${client.playerState})" } true } catch (t: Throwable) { - Log.w(TAG, "cast: remoteMediaClient.load failed: ${t.message}", t) + Log.w(TAG, "cast: remoteMediaClient.load failed", t) false } } @@ -493,13 +493,13 @@ class ChromecastCaster( stopAck.complete(status.statusCode) } } catch (t: Throwable) { - Log.w(TAG, "remoteMediaClient.stop threw: ${t.message}", t) + 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") + Log.w(TAG) { "remoteMediaClient.stop did not ack within ${STOP_AWAIT_TIMEOUT_MS}ms" } } } withContext(Dispatchers.Main) { @@ -510,7 +510,7 @@ class ChromecastCaster( // so the next cast can reuse the connection cleanly. castContext?.sessionManager?.endCurrentSession(false) } catch (t: Throwable) { - Log.w(TAG, "endCurrentSession failed: ${t.message}", t) + Log.w(TAG, "endCurrentSession failed", t) } mediaRouter?.unselect(MediaRouter.UNSELECT_REASON_STOPPED) }