diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 32a65cb98..4549d5a04 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 e730119cc..e55a4c2d2 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 000000000..e7fbe561e --- /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 f49058bee..9c914026f 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 180ce67bd..cc358f57b 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 540106e85..ed4d71e42 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 59469a96d..37c277a11 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 e1ebf5fa8..2b043bb77 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 8a5795636..ef96a8b97 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 28be3d53e..3aef11fa2 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 000000000..a17e20865 --- /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 000000000..776b452db --- /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 000000000..a7ad156a8 --- /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 000000000..ea4f40864 --- /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 1bf719a92..54468f21a 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 a565b2376..5c12f6497 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 000000000..b9f2ac1f3 --- /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 5b647776f..805e1d80d 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 e6cd4ad1f..5b8e92191 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 967a4bfe4..bbb7aef1a 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 d0d019e9a..aafa18bfb 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 000000000..37f161512 --- /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 000000000..0283f63b8 --- /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 c9276464a..679414e70 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 8bd3931f3..c2a3b64e0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -55,6 +55,8 @@ object MaterialSymbols { val CameraFront = MaterialSymbol("\uF2C9") val CameraRear = MaterialSymbol("\uF2C8") val Cancel = MaterialSymbol("\uE888") + val Cast = MaterialSymbol("\uE307") + val CastConnected = MaterialSymbol("\uE308") val CellTower = MaterialSymbol("\uEBBA") val Chat = MaterialSymbol("\uE0C9") val Check = MaterialSymbol("\uE5CA") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27f67a9cd..105d115d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,6 +65,13 @@ 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" 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" } 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" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }