From 5df0e2a37a2198b2b363d07872a71445353ec8fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 08:53:12 +0000 Subject: [PATCH] Add LAN video casting (Chromecast on play, DLNA on both flavors) v1 fix(cast): handle every Chromecast session terminal state and HLS mime hint fix(cast): repair DLNA Res ctor and add play-only protocol toggle fix(cast): backfill Cast button for accounts with pre-existing video settings fix(cast): include cast glyphs in symbol font subset and add diagnostic logs fix(cast): wire jUPnP's required Jetty deps and survive dialog dismissal fix(cast): add jetty-client so jUPnP can issue UPnP control requests --- amethyst/build.gradle | 19 + amethyst/proguard-rules.pro | 8 + .../cast/chromecast/ChromecastCaster.kt | 57 +++ amethyst/src/main/AndroidManifest.xml | 5 + .../com/vitorpamplona/amethyst/AppModules.kt | 9 + .../amethyst/model/AccountSyncedSettings.kt | 5 +- .../model/AccountSyncedSettingsInternal.kt | 12 + .../amethyst/model/UiSettings.kt | 18 + .../amethyst/model/UiSettingsFlow.kt | 9 + .../model/preferences/UISharedPreferences.kt | 4 + .../amethyst/service/cast/CastDevice.kt | 76 ++++ .../amethyst/service/cast/CastRegistry.kt | 185 ++++++++ .../amethyst/service/cast/VideoCaster.kt | 63 +++ .../amethyst/service/cast/dlna/DlnaCaster.kt | 380 ++++++++++++++++ .../composable/controls/OverflowMenu.kt | 14 + .../composable/controls/RenderTopButtons.kt | 27 ++ .../ui/cast/CastDevicePickerDialog.kt | 124 ++++++ .../loggedIn/settings/AppSettingsScreen.kt | 27 ++ .../settings/VideoPlayerSettingsScreen.kt | 2 + amethyst/src/main/res/values/strings.xml | 12 + amethyst/src/play/AndroidManifest.xml | 6 + .../chromecast/AmethystCastOptionsProvider.kt | 37 ++ .../cast/chromecast/ChromecastCaster.kt | 410 ++++++++++++++++++ .../font/material_symbols_outlined.ttf | Bin 418444 -> 425000 bytes .../commons/icons/symbols/MaterialSymbols.kt | 2 + gradle/libs.versions.toml | 14 + 26 files changed, 1523 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/AmethystCastOptionsProvider.kt create mode 100644 amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 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 c9276464a3c5179247eb8b91709096843459bdce..679414e7085a236808be26b926b6ff6c4594b627 100644 GIT binary patch delta 9120 zcmb7K34B!5x&O{R_s*7^*)nUknaRGeC4{gFfj|t45Ed<9i-biHL8(Q=%Pflt-}EipsOp0!5$@FrtkE1_;?m!jM^#nR(y2lT0QF`rbQ}v)ps|L7i?OzaWBBBG62ps zZ{f)NscY{m0yy|QzO{VeqN$6XYv1@wfPam`>!anH${z*s`T@Ll<8|4}_2nB@{%TJK zKHmr+i@zW5Z@;;IKTei~_gfw)|Cw_GHnce5F{~92tb5{qST(r-KrjIOVaEgbLhiPe ztpI1IgYeGfHLJ^4jZI2_3gAzh@%h3v*dhN;Zo~;z;dR=Y^&2@EYj@Vo>=D^N&#uo7LD zEvF{uYOa_&BX?Eq%ejBd{W7<8SoW~_!yX^@-SEWW+lC(*?jMmh;{3?+k-JBp8AV5B zjk;^pmQh#o=I5=>+m!cQ-fMa9e16j zZyLR4^j8Jyg82n+7j%w^AG2c28)N=9=KPpzh04N-g{6f*DSW)}?ZU6d#*B50oji8O z*iXj^<4VRoG_GR&u<5n(|4JqG(Fd(xO+3{x;P(_1>xPPxVeqo3?4%htsYV zL$RqiwRlSLg5veX6~*rre_DL8_*!vb`kd*X&T!0lV8*T)7fSe&y(KsAEWh)3saU$H z^!QBs%+i@PGn;3X&e}BV>)E>5J7-^-^V2!U@A}DIpU*9v`|#X@b6xY6&f7oFQYUEzYL0jkKlPl(wnK&B<>hzmWW(tulEuK=PsF z{doOe^4rOylV>H*NLtI@PU=Z|F|j#uUE&LgZzZV{pG+(SNW6|YE-@!jov#g{__)=?!^$qI+%TdcXD+RC|uq-q0Hg~YWEl-($ zZOO+PaTLIO+I-CXrFolqq2&q7L*{PFCiAD}1nj?Io@&W9T{N9DFEKx3o@D;B=_~V8 zvkky>#?)iFY5LCe7Pjvb3v3|9>e#-AF88D7xEYQNCkr@Npn zXA=QvE@?bkQFBzY1EbM;yzbU4*HmJCLG!Ifp+2O3QUmHjO}0j-#$Xb2KIX^rwbGJ> zUzbW!hHFtq7WH>kzjweo6EDfA{=U8zv6Jw|?CX$fV#22}_a`EnKwO~Bpf z1#abEfLr}Ja2rJ69=iv)r?@;pYYv7df@(i1h|j(0Qa#AxX+dXca#I}^Df|y)q;~dc|UMpJPX{}$-vb< z1Kb51pd)?_bmSJGqt*ex5Ge&8QH2K?i@fPdx!@LLms-?0h!oe0(ZFJA)wbsY4aEx_-`iQaDk{?pfi z|Jxzpzc>v1IV6!6-N65QIq+ATfN$CeeCPAP-zo(@;0A##2LXLT(Cq*rDGP)YF9;)6 zfKc#j5Q>UGn7$c=nZE#G&Lj|ignd7L7lc&>AlzRE!oy4Pg-5aTsc9fMUk71p2?)<0 z0b%Dl5Pq=~gf}*T@Sl4?*t@&xNbPe+YRLyjYPluTomN-_*$@W{>~dIT=b_jRFyF3% zwRR4c*$J{u6HK<7vD9GJfx~XW+6Z^smH3Pd-&4Q_y8x^0F|gQ9!DKh!^J+-9YoWld zhirQ+J};`2Krm71_vqp~Lk|Xzu%8o=O#00XX+s~v+X#T|;U8XWkW9{WiRMSOjm2a zDJZar{26<4(m*3K6H9iG?96dVZjpD3_4V~$(dkyCU{ch#wbeHV0=^sdje?j|-z2(u zvGVdE(mSY zb;qP+3%#iu6Crz;A)XbmVC%mXbCvSUxlV;b;l-r1aNoHaFcVSYt_(VBt zw6p+s-5cz+0cmp`uHb{-h|&H4p;wi>pNgXB#-tRfpJxqGOo$)s%!G5YMU~tmUN9Pk zZlAZSTR`1<2=RBxV&q(RSC6QT_xSuh)M%m#1?>rVJbq3d>Y;K`rxQFW4jh%pWdjzs z*U(ah!#e`aEv*46YPCul@U&y{Xk*YKTA9=9^>bQX$pEeFnNMhsnzf* zt&+z_iQm)I?!)aEo(km+J0-d`ZcXMv#3~pKO#ZGu`NQIO@uJr1@iOTn;&*d$kB2IB zqO3d3iC)R1XEx|h;daE~xzwI3R~vhHxyE1+`5rf}NQ>JL%a%FT(ADLm2Bo+4N@|>1 zP6OAjGz4z7V!9QeF>!icjEh5OlgDyCcSEx$&S94j1(%))&DNh)RKkPaeZbp89id=H ztU6W|3WXH09ax4!v7t~fB*cb-5aMDhl{7}(+1c3>==5yiJ)LT0OrWq#FAWGv8QxMN zn28XfVyk3So01s_P|(y}^l4dQOUu%3AiIEU(`meQwQMN2;FLqd=StG}!F<)=@`3{5T%S*RVNPPUx7PD6Vuhf)~uuJu*M+wK;M;0V{jUNha>J%`a>M0G=ND- zxqyc?D6J5YXqI4LT8x3|4h&3lFff&1V7eOv(>x47OYvxACI%pkZy2;`D95nQa3G8i#-MD7Fv~-g8;UdA+8xVS2?YX)3 zm>d1kLMKb`UB+N=xg{vZ4ZqwGn=&9$#OcH1qTiu#)Jbs%37oz^A_#S7D2owZeRMd8 zlLCa@Vn$`6A_3Dhte$yb9AJ(ng$E08lG2aG)kWLcw`!l3Va?<69S-Iqfz+Vng&2*mD$ALL$V=Ly?;eaT|NG;0K;$w zf<~<(42^;*;2{wNbaqDxY3Rc=gPa97A5~-6@wHsR)Z&Z5jnoHzw|c5_^gJ)>^&*eK zMTRx!b-No7;S|Vryv+>*K(H#_igNY2O}{gvxQKsN#I_*I4B4;+5zy;^m`Zscs8#X^ zZGCVDY<+$X|Fk`L3tV0TF!P@q3B$uv_3l4ub);Pw#Z6I^ELmoHvbCkSxOgfe zP80_n!cnujyE%=qxw#`K3?!pN$f_-Q!2IYIefz(Uv{odYJ$J5F@%6>8FGk~1guW(g zwQ3borLNBQXpD;RSY}I1s3C{8?DIR;6XxD*8#|10rPh$`Jbk)$AaX@fTV+ejwQuXP zvQiA~mAYsQ>*ct_&@y~jWZ4u937I3}PokSlhDeg2gCx{7-13VJU8xg$G0xdbYNJ`N z5C%L;R#p&MTlb~9f$TOye-#n>o0-w8Fo({=49Zkz<$!MulqJI*>~`O1NJvcR1x9C> z@%9tqUUbY5MV&dZq2Y#m0QDV0jJFG&=gyu!)p>Zkpil@wzaJYdR@<237Nov!La1uH z%xIK#HaC+>=5P|{#d<4)Rr2O`!KtV{|JBzD>2=!U^z9Zg)T>id!M{{&u0^#;ury2JB2sRJAoU98OD(UUa5En}eAEi^vvA70qU&S#C{C z3!}I*!f~VNZ>q=R?dd^6pcsVc(L6Xer*A?+*xL7*U7B<-BhCr5wza!e-`3Z=SR`Ok zK;pg5j^?4CJd8b4p53ZIcPrPI554 z`>CuJ2+)**g*;X<7hU7`px zBh}79ta=!wLC?+DSS1&TL+%_+oo_?0`=N|&bWFWlkjI7);nV9NI1fI3iO8u$SXPJ;C5%i80@c5fqP<@Ms3`2uFp{lf!F*$2&^c zgpslwBjqNHlVpAHf7XEU$H2i=Y03O-|u_hd;fQ4Yqx&+ zA-w|(Krfmk)R|+7(pFa1?g2h0kS)j>YcH5r|4J8dYyrT(7(FI3+CFQ_P~f9ZGMGB1 z&|dgvXXP5;L?OwoCG$(>!u#7spk0!j_u}l5Iqe5#CIJn3fXb(o^nblS`xBr|NBY|{ zO6ED{&_WFy?I!Lyqx|*KDL*~%q~rm>X3QXmq`dGp3Rnom8DBQNWLn&n*0I3Wn`9qX zMu5kEJp8Gk<0SVln_XFzADz1b_|QTHHR5etK8SVWH_*^9O(0w%TJk^t%?R2P>=`^VxHkCU0ONph0~!W=6EZ4fO~~$$BO#rks?gNX^3aW;heDr( z1%_pWm4>Yh+Y)wlpw~d_z;y%v9iAHgdblh6(x4R)hKO+y%OaX1zK&EyCPi+rdRa}@ zNb68*zV#2*dDf-Y)z(JqG3%Wu8D)%$i5lsMs*KtYbtLNN=*Z{^(W|15#CXM&#C#NU z#%8n?+7{SW*y?QuY!6~ttR~hPTN1k}wl(&A?8CUsxT?7QaZd;LADl4wdi?PCKgRzf z{(gc!VOGN13A+-$NVteMD2)UBS(&`8x=OH zCR>y3$i9~|Am^o=wYhzAXXG~KUK$-edfw=Jd1-m8t$7{!+4)=Yy9-ha7CpPe{O;KB zg{pB=e3QF8xoDx|#SIzX?-xFy*|qM|!OV4`u^;zV9?>yQqOCGJ9TW(vDEq5%}EcND}=z?5yyPI#AuUgdRZ1Y}Axp||xkJ-x{X$~^?FsGPqncg!k zGtD&r$rJ;a8co|wYfK-QHko2f876z+EFiEu@Qr|50p$V90yYGC1yl#b0Rh*E`UC_A zcp1x#-bQc0=wYNg3^)v3hWmyq#&mB$W zzjvGKUem7AMG#MSOWJnrQEi9z4ec1+tGXH5hq}4iy;>vTx3qR$kmkJRjJ8NytxeYM z(0r*)aceZkHQkzfny)k)XkDtgt#NGC+|f+Ye5Nt`zv`ducf;?R|7`ydzh%98s!#Nq z*z2^q#GMH6{o41T+S|9)m%cRhOp+h^PW4?({Iu@{Ur(Q%KGi;ted2tBe0%z=1)e+g z+<1>!&kpGq_h&Q9rBldd1^)-ZnbYL&tly!eGwSO1gPDGhP=MaQ(El`y9uLA)OzpHc zJ#YNsH_dHy{<^+P{4P43Z|~5ui)5wmdH6RgV87dsickgCVI62^ejD%5%1!REioE?D zi=M4%Vu8$z$;^{^vEEFL8j61u2hobdh(Rohu@Wsfjsv)kBRGV8XvTgFfgWM-#ua>x za|mH+*oq9wS%(gsW$7#fQMk&IX`>>M$g)w+qN$z$oMd{OVm547Q8~4MD}Xr}vpO63 zW}2IMs&lodbt-!-^i~D$*~VR8>`nA=r62rr4=q&+_Vg)y-5fp$fsLSS=jG!`Y_ZdD zVtwXzxUdUNxQJW0hwpJ8U3iE`c#J3H!kMJ4b+_rw)YPg!(=t6Xy4z>Zvwkd;4P=9u zl|?(BoG>{pZACqbo^6qiN?F*pjXSNUK6eh1H=SLlihGo^BKM_G*<9mKXK+Vm9{7mK z;5jDn(c$0|=7LXd2A|#vKFbrlVm|mQS>W?4!C(Io+(B5)H1OrU!B<`ccUFMc7J{#_ zfxk<>>#M-)gTXhez(2?U|ELB0<1+BZN#HvY!FL@8-~Ao<-c8{9uED|oNkNVs25+kd z?^p1a3p{N(k`;LRAgX^Bja<9)y1~geD9^>j$CZ zL^mPyry&eGA&hkpfocd-FoY!@;`s`QzFQ#rU4{tKI3NbpK!o0f2&cf2PJS0HYALUd6h-60TIWFhgQA&}#%ASVzn-U>O<4{}ljyRH`RD-@ z)e0!ylc8vjLNReDp