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
This commit is contained in:
Claude
2026-05-10 08:53:12 +00:00
committed by davotoula
parent 89c711ca9f
commit 5df0e2a37a
26 changed files with 1523 additions and 2 deletions
+19
View File
@@ -379,6 +379,25 @@ dependencies {
//PushNotifications(FDroid) //PushNotifications(FDroid)
fdroidImplementation libs.unifiedpush 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 // Charts
implementation libs.vico.charts.compose implementation libs.vico.charts.compose
implementation libs.vico.charts.m3 implementation libs.vico.charts.m3
+8
View File
@@ -69,3 +69,11 @@
-keep class * extends androidx.room.RoomDatabase { -keep class * extends androidx.room.RoomDatabase {
<init>(); <init>();
} }
# 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.**
@@ -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<List<CastDevice>> = MutableStateFlow<List<CastDevice>>(emptyList()).asStateFlow()
override val sessionState: StateFlow<CastSessionState> = MutableStateFlow<CastSessionState>(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
}
+5
View File
@@ -42,6 +42,11 @@
(otherwise the wrapper waits ~30s for QUIC PTO before reconnecting). --> (otherwise the wrapper waits ~30s for QUIC PTO before reconnecting). -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- DLNA SSDP discovery (multicast 239.255.255.250:1900) for the LAN cast
feature. Acquired only while the cast device picker is open. -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Audio/Video Playback --> <!-- Audio/Video Playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState 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.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
@@ -492,6 +493,14 @@ class AppModules(
Nip95CacheFactory.new(appContext) 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 // local video cache with disk + memory
val videoCache: VideoCache by lazy { val videoCache: VideoCache by lazy {
Log.d("AppModules", "VideoCache Init") Log.d("AppModules", "VideoCache Init")
@@ -60,7 +60,7 @@ class AccountSyncedSettings(
) )
val videoPlayer = val videoPlayer =
AccountVideoPlayerPreferences( AccountVideoPlayerPreferences(
MutableStateFlow(internalSettings.videoPlayer.buttonItems.toImmutableList()), MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
) )
fun toInternal(): AccountSyncedSettingsInternal = fun toInternal(): AccountSyncedSettingsInternal =
@@ -150,7 +150,8 @@ class AccountSyncedSettings(
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag) 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)) { if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems) videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
} }
@@ -75,6 +75,7 @@ enum class VideoPlayerAction {
Share, Share,
Download, Download,
PictureInPicture, PictureInPicture,
Cast,
} }
@Serializable @Serializable
@@ -94,11 +95,22 @@ val DefaultVideoPlayerButtonItems =
VideoPlayerButtonItem(VideoPlayerAction.Fullscreen, VideoButtonLocation.TopBar), VideoPlayerButtonItem(VideoPlayerAction.Fullscreen, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar), VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar), VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu), VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu), VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu), VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
) )
// Existing accounts have a button-items list serialized before some actions
// existed (e.g. Cast was added later). Append any default items the saved list
// is missing so new actions surface without forcing a settings reset — the
// user's existing order/locations for actions they already have are preserved.
fun mergeWithDefaultVideoPlayerButtons(saved: List<VideoPlayerButtonItem>): List<VideoPlayerButtonItem> {
val knownActions = saved.mapTo(mutableSetOf()) { it.action }
val missing = DefaultVideoPlayerButtonItems.filter { it.action !in knownActions }
return if (missing.isEmpty()) saved else saved + missing
}
fun getLanguagesSpokenByUser(): Set<String> { fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration()) val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>() val codedList = mutableSetOf<String>()
@@ -47,6 +47,7 @@ data class UiSettings(
val showHomeNewThreadsTab: Boolean = true, val showHomeNewThreadsTab: Boolean = true,
val showHomeConversationsTab: Boolean = true, val showHomeConversationsTab: Boolean = true,
val showHomeEverythingTab: Boolean = false, val showHomeEverythingTab: Boolean = false,
val castProtocol: CastProtocolType = CastProtocolType.BOTH,
) )
enum class ThemeType( enum class ThemeType(
@@ -147,6 +148,23 @@ fun parseBooleanType(screenCode: Int): BooleanType =
else -> BooleanType.ALWAYS 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( enum class WarningType(
val prefCode: Boolean?, val prefCode: Boolean?,
val screenCode: Int, val screenCode: Int,
@@ -47,6 +47,7 @@ class UiSettingsFlow(
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true), val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true), val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false), val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
val castProtocol: MutableStateFlow<CastProtocolType> = MutableStateFlow(CastProtocolType.BOTH),
) { ) {
val listOfFlows: List<Flow<Any?>> = val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>( listOf<Flow<Any?>>(
@@ -68,6 +69,7 @@ class UiSettingsFlow(
showHomeNewThreadsTab, showHomeNewThreadsTab,
showHomeConversationsTab, showHomeConversationsTab,
showHomeEverythingTab, showHomeEverythingTab,
castProtocol,
) )
// emits at every change in any of the propertyes. // emits at every change in any of the propertyes.
@@ -93,6 +95,7 @@ class UiSettingsFlow(
flows[15] as Boolean, flows[15] as Boolean,
flows[16] as Boolean, flows[16] as Boolean,
flows[17] as Boolean, flows[17] as Boolean,
flows[18] as CastProtocolType,
) )
} }
@@ -116,6 +119,7 @@ class UiSettingsFlow(
showHomeNewThreadsTab.value, showHomeNewThreadsTab.value,
showHomeConversationsTab.value, showHomeConversationsTab.value,
showHomeEverythingTab.value, showHomeEverythingTab.value,
castProtocol.value,
) )
fun update(torSettings: UiSettings): Boolean { fun update(torSettings: UiSettings): Boolean {
@@ -193,6 +197,10 @@ class UiSettingsFlow(
showHomeEverythingTab.tryEmit(torSettings.showHomeEverythingTab) showHomeEverythingTab.tryEmit(torSettings.showHomeEverythingTab)
any = true any = true
} }
if (castProtocol.value != torSettings.castProtocol) {
castProtocol.tryEmit(torSettings.castProtocol)
any = true
}
return any return any
} }
@@ -230,6 +238,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showHomeNewThreadsTab), MutableStateFlow(uiSettings.showHomeNewThreadsTab),
MutableStateFlow(uiSettings.showHomeConversationsTab), MutableStateFlow(uiSettings.showHomeConversationsTab),
MutableStateFlow(uiSettings.showHomeEverythingTab), MutableStateFlow(uiSettings.showHomeEverythingTab),
MutableStateFlow(uiSettings.castProtocol),
) )
} }
} }
@@ -32,6 +32,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.CastProtocolType
import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ProfileGalleryType
@@ -108,6 +109,7 @@ class UiSharedPreferences(
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts")
val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items") val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items")
val UI_CAST_PROTOCOL = stringPreferencesKey("ui.cast_protocol")
suspend fun uiPreferences(context: Context): UiSettings? = suspend fun uiPreferences(context: Context): UiSettings? =
try { try {
@@ -134,6 +136,7 @@ class UiSharedPreferences(
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems, bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems,
castProtocol = preferences[UI_CAST_PROTOCOL]?.let { CastProtocolType.valueOf(it) } ?: CastProtocolType.BOTH,
) )
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -173,6 +176,7 @@ class UiSharedPreferences(
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name } preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name }
preferences[UI_CAST_PROTOCOL] = sharedSettings.castProtocol.name
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -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()
}
@@ -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<CastProtocolType> =
MutableStateFlow(CastProtocolType.BOTH),
) {
private val casters: List<VideoCaster> =
listOf(
ChromecastCaster(appContext),
DlnaCaster(appContext),
)
val devices: StateFlow<List<CastDevice>> =
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<CastSessionState> =
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<String, Boolean>().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() } }
}
}
@@ -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<List<CastDevice>>
/** Status of the active casting session owned by this caster. */
val sessionState: StateFlow<CastSessionState>
/**
* 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()
}
@@ -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<List<CastDevice>>(emptyList())
override val devices: StateFlow<List<CastDevice>> = devicesFlow.asStateFlow()
private val sessionFlow = MutableStateFlow<CastSessionState>(CastSessionState.Idle)
override val sessionState: StateFlow<CastSessionState> = sessionFlow.asStateFlow()
private val knownDevices = ConcurrentHashMap<String, RemoteDevice>()
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<Boolean>()
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<Boolean>()
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<Unit>()
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)
""
}
}
@@ -70,6 +70,7 @@ fun OverflowMenuButtonPreview() {
onShareClick = {}, onShareClick = {},
onSaveClick = {}, onSaveClick = {},
onPipClick = {}, onPipClick = {},
onCastClick = {},
) )
} }
} }
@@ -86,6 +87,7 @@ fun AnimatedOverflowMenuButton(
onShareClick: () -> Unit, onShareClick: () -> Unit,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
onPipClick: () -> Unit, onPipClick: () -> Unit,
onCastClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( AnimatedVisibility(
@@ -103,6 +105,7 @@ fun AnimatedOverflowMenuButton(
onShareClick = onShareClick, onShareClick = onShareClick,
onSaveClick = onSaveClick, onSaveClick = onSaveClick,
onPipClick = onPipClick, onPipClick = onPipClick,
onCastClick = onCastClick,
) )
} }
} }
@@ -117,6 +120,7 @@ fun OverflowMenuButton(
onShareClick: () -> Unit, onShareClick: () -> Unit,
onSaveClick: () -> Unit, onSaveClick: () -> Unit,
onPipClick: () -> Unit, onPipClick: () -> Unit,
onCastClick: () -> Unit,
) { ) {
val menuExpanded = remember { mutableStateOf(false) } val menuExpanded = remember { mutableStateOf(false) }
@@ -211,6 +215,16 @@ fun OverflowMenuButton(
onPipClick() onPipClick()
} }
} }
VideoPlayerAction.Cast -> {
M3ActionRow(
icon = MaterialSymbols.Cast,
text = stringRes(R.string.cast_to_device),
) {
menuExpanded.value = false
onCastClick()
}
}
} }
} }
} }
@@ -55,11 +55,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.VideoButtonLocation import com.vitorpamplona.amethyst.model.VideoButtonLocation
import com.vitorpamplona.amethyst.model.VideoPlayerAction 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.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity 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.ShareMediaAction
import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -197,6 +199,7 @@ fun RenderTopButtons(
) { ) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle() val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val shareDialogVisible = remember { mutableStateOf(false) } val shareDialogVisible = remember { mutableStateOf(false) }
val castDialogVisible = remember { mutableStateOf(false) }
val saveAction = val saveAction =
rememberSaveMediaAction { context -> rememberSaveMediaAction { context ->
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
@@ -210,6 +213,7 @@ fun RenderTopButtons(
VideoPlayerAction.Share -> true VideoPlayerAction.Share -> true
VideoPlayerAction.Download -> !isLive VideoPlayerAction.Download -> !isLive
VideoPlayerAction.PictureInPicture -> pipSupported VideoPlayerAction.PictureInPicture -> pipSupported
VideoPlayerAction.Cast -> mediaData.videoUri.startsWith("http", ignoreCase = true)
} }
val canFullscreen = onZoomClick != null val canFullscreen = onZoomClick != null
@@ -281,6 +285,15 @@ fun RenderTopButtons(
onClick = onPictureInPictureClick, 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 }, onShareClick = { shareDialogVisible.value = true },
onSaveClick = saveAction, onSaveClick = saveAction,
onPipClick = onPictureInPictureClick, 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 },
) )
} }
@@ -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),
)
}
}
}
@@ -53,14 +53,17 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.CastProtocolType
import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseCastProtocolType
import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseFeatureSetType
import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseGalleryType
@@ -128,6 +131,9 @@ fun SettingsScreen(
ShowImagePreviewChoice(sharedPrefs) ShowImagePreviewChoice(sharedPrefs)
ShowVideoPlaybackChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs)
AutoplayVideosChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs)
if (BuildConfig.FLAVOR == "play") {
CastProtocolChoice(sharedPrefs)
}
ShowUrlPreviewChoice(sharedPrefs) ShowUrlPreviewChoice(sharedPrefs)
ShowProfilePictureChoice(sharedPrefs) ShowProfilePictureChoice(sharedPrefs)
ImmersiveScrollingChoice(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 @Composable
fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) { fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) {
val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState() val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState()
@@ -330,6 +330,7 @@ fun videoPlayerActionName(action: VideoPlayerAction): String =
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share) VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share)
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download) VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download)
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip) VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip)
VideoPlayerAction.Cast -> stringRes(R.string.video_player_settings_action_cast)
} }
@Composable @Composable
@@ -341,4 +342,5 @@ fun videoPlayerActionDescription(action: VideoPlayerAction): String =
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share_description) VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share_description)
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download_description) VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download_description)
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip_description) VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip_description)
VideoPlayerAction.Cast -> stringRes(R.string.video_player_settings_action_cast_description)
} }
+12
View File
@@ -1887,6 +1887,8 @@
<string name="video_player_settings_action_download_description">Download the video to your device (hidden on live streams)</string> <string name="video_player_settings_action_download_description">Download the video to your device (hidden on live streams)</string>
<string name="video_player_settings_action_pip">Picture-in-Picture</string> <string name="video_player_settings_action_pip">Picture-in-Picture</string>
<string name="video_player_settings_action_pip_description">Play the video in a floating window (hidden if unsupported)</string> <string name="video_player_settings_action_pip_description">Play the video in a floating window (hidden if unsupported)</string>
<string name="video_player_settings_action_cast">Cast to Device</string>
<string name="video_player_settings_action_cast_description">Send the video to a Chromecast or DLNA receiver on your Wi-Fi (hidden for local files)</string>
<string name="profile_image_of_user">Profile Picture of %1$s</string> <string name="profile_image_of_user">Profile Picture of %1$s</string>
<string name="relay_info">Relay %1$s</string> <string name="relay_info">Relay %1$s</string>
@@ -2555,6 +2557,16 @@
<string name="playback_actions_dialog_title">Playback</string> <string name="playback_actions_dialog_title">Playback</string>
<string name="video_quality_auto">Auto</string> <string name="video_quality_auto">Auto</string>
<!-- LAN cast (Chromecast / DLNA) feature -->
<string name="cast_to_device">Cast to device</string>
<string name="cast_to_device_dialog_title">Cast to…</string>
<string name="cast_searching_for_devices">Searching for devices on your Wi-Fi…</string>
<string name="cast_protocol_setting_title">Cast protocol</string>
<string name="cast_protocol_setting_description">Which devices to scan for when casting videos on your Wi-Fi</string>
<string name="cast_protocol_both">Chromecast &amp; UPnP</string>
<string name="cast_protocol_chromecast">Chromecast only</string>
<string name="cast_protocol_dlna">UPnP / DLNA only</string>
<!-- HLS multi-resolution video sharing --> <!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS Upload</string> <string name="share_hls_video">HLS Upload</string>
<string name="share_hls_video_drawer_description">Publish multi-resolution HLS to your media server</string> <string name="share_hls_video_drawer_description">Publish multi-resolution HLS to your media server</string>
+6
View File
@@ -32,6 +32,12 @@
android:name="com.google.firebase.messaging.default_notification_channel_id" android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/app_notification_channel_id" /> android:value="@string/app_notification_channel_id" />
<!-- Google Cast SDK options provider. Only declared in the play
flavor because the SDK hard-depends on Google Play services. -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" />
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.cast.chromecast
import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
class AmethystCastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions =
CastOptions
.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.build()
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
}
@@ -0,0 +1,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<List<CastDevice>>(emptyList())
override val devices: StateFlow<List<CastDevice>> = devicesFlow.asStateFlow()
private val sessionFlow = MutableStateFlow<CastSessionState>(CastSessionState.Idle)
override val sessionState: StateFlow<CastSessionState> = sessionFlow.asStateFlow()
private val main = Handler(Looper.getMainLooper())
private var mediaRouter: MediaRouter? = null
private var routeSelector: MediaRouteSelector? = null
@Volatile
private var castContext: CastContext? = null
private var registered = false
private val routes = mutableMapOf<String, MediaRouter.RouteInfo>()
private val routerCallback =
object : MediaRouter.Callback() {
override fun onRouteAdded(
router: MediaRouter,
route: MediaRouter.RouteInfo,
) {
Log.d(TAG) { "onRouteAdded id=${route.id} name=${route.name}" }
updateRoutes(router)
}
override fun onRouteRemoved(
router: MediaRouter,
route: MediaRouter.RouteInfo,
) {
Log.d(TAG) { "onRouteRemoved id=${route.id} name=${route.name}" }
updateRoutes(router)
}
override fun onRouteChanged(
router: MediaRouter,
route: MediaRouter.RouteInfo,
) {
Log.d(TAG) { "onRouteChanged id=${route.id} name=${route.name} selected=${route.isSelected}" }
updateRoutes(router)
}
}
private val sessionListener =
object : SessionManagerListener<CastSession> {
override fun onSessionStarting(session: CastSession) {
Log.d(TAG) { "session.onStarting" }
}
override fun onSessionStarted(
session: CastSession,
sessionId: String,
) {
Log.d(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" }
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<Boolean>? = null
private fun currentDevice(): CastDevice? =
when (val s = sessionFlow.value) {
is CastSessionState.Connecting -> s.device
is CastSessionState.Casting -> s.device
else -> null
}
private fun ensureCastContext(): CastContext? {
castContext?.let { return it }
val gms = GoogleApiAvailability.getInstance()
val status = gms.isGooglePlayServicesAvailable(appContext)
if (status != ConnectionResult.SUCCESS) {
Log.d(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." }
return null
}
return try {
// The blocking overload is the right choice here: we only call it on
// the main thread, OptionsProvider is declared in the play manifest,
// and the SDK caches the singleton after the first call.
@Suppress("DEPRECATION")
CastContext.getSharedInstance(appContext).also { castContext = it }
} 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<Boolean>()
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
}
}
@@ -55,6 +55,8 @@ object MaterialSymbols {
val CameraFront = MaterialSymbol("\uF2C9") val CameraFront = MaterialSymbol("\uF2C9")
val CameraRear = MaterialSymbol("\uF2C8") val CameraRear = MaterialSymbol("\uF2C8")
val Cancel = MaterialSymbol("\uE888") val Cancel = MaterialSymbol("\uE888")
val Cast = MaterialSymbol("\uE307")
val CastConnected = MaterialSymbol("\uE308")
val CellTower = MaterialSymbol("\uEBBA") val CellTower = MaterialSymbol("\uEBBA")
val Chat = MaterialSymbol("\uE0C9") val Chat = MaterialSymbol("\uE0C9")
val Check = MaterialSymbol("\uE5CA") val Check = MaterialSymbol("\uE5CA")
+14
View File
@@ -65,6 +65,13 @@ tarsosdsp = "2.5"
translate = "17.0.3" translate = "17.0.3"
jetbrainsCompose = "1.10.3" jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10" 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" vico-charts-compose = "3.1.0"
zelory = "3.0.1" zelory = "3.0.1"
zoomable = "2.11.1" zoomable = "2.11.1"
@@ -188,6 +195,13 @@ schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr25
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" } stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } 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-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" } vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }