Add LAN video casting (Chromecast on play, DLNA on both flavors) v1
fix(cast): handle every Chromecast session terminal state and HLS mime hint fix(cast): repair DLNA Res ctor and add play-only protocol toggle fix(cast): backfill Cast button for accounts with pre-existing video settings fix(cast): include cast glyphs in symbol font subset and add diagnostic logs fix(cast): wire jUPnP's required Jetty deps and survive dialog dismissal fix(cast): add jetty-client so jUPnP can issue UPnP control requests
This commit is contained in:
@@ -379,6 +379,25 @@ dependencies {
|
|||||||
//PushNotifications(FDroid)
|
//PushNotifications(FDroid)
|
||||||
fdroidImplementation libs.unifiedpush
|
fdroidImplementation libs.unifiedpush
|
||||||
|
|
||||||
|
// DLNA / UPnP discovery and AVTransport — used by the LAN cast feature in
|
||||||
|
// both flavors. jUPnP is the maintained fork of Cling.
|
||||||
|
implementation libs.jupnp.core
|
||||||
|
implementation libs.jupnp.android
|
||||||
|
implementation libs.jupnp.support
|
||||||
|
// jUPnP-android's AndroidUpnpServiceConfiguration.createStreamServer wires
|
||||||
|
// in JettyServletContainer (needs jetty-server + jetty-servlet) AND
|
||||||
|
// createStreamClient wires in JettyStreamClientImpl (needs jetty-client) —
|
||||||
|
// without all three jUPnP throws NoClassDefFoundError on startup() and
|
||||||
|
// DLNA discovery never starts.
|
||||||
|
implementation libs.jetty.server
|
||||||
|
implementation libs.jetty.servlet
|
||||||
|
implementation libs.jetty.client
|
||||||
|
|
||||||
|
// Google Cast SDK — Chromecast support. Play flavor only because the
|
||||||
|
// framework hard-depends on Google Play services, which is unavailable
|
||||||
|
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
|
||||||
|
playImplementation libs.play.services.cast.framework
|
||||||
|
|
||||||
// Charts
|
// Charts
|
||||||
implementation libs.vico.charts.compose
|
implementation libs.vico.charts.compose
|
||||||
implementation libs.vico.charts.m3
|
implementation libs.vico.charts.m3
|
||||||
|
|||||||
Vendored
+8
@@ -69,3 +69,11 @@
|
|||||||
-keep class * extends androidx.room.RoomDatabase {
|
-keep class * extends androidx.room.RoomDatabase {
|
||||||
<init>();
|
<init>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# jUPnP ships optional Jetty + OSGi transport adapters that Android doesn't
|
||||||
|
# use (we run jupnp-android's Servlet-less transport). Tell R8 to ignore the
|
||||||
|
# dangling references rather than fail the minify step.
|
||||||
|
-dontwarn org.eclipse.jetty.**
|
||||||
|
-dontwarn org.osgi.**
|
||||||
|
-dontwarn javax.servlet.**
|
||||||
|
-dontwarn org.jupnp.transport.impl.jetty.**
|
||||||
|
|||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.cast.chromecast
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastDevice
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.VideoCaster
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F-Droid stub: the Google Cast SDK requires Google Play services, which are
|
||||||
|
* not present on the FOSS build. This caster always reports an empty device
|
||||||
|
* list so the registry simply skips Chromecast paths at runtime.
|
||||||
|
*/
|
||||||
|
@Suppress("UNUSED_PARAMETER")
|
||||||
|
class ChromecastCaster(
|
||||||
|
appContext: Context,
|
||||||
|
) : VideoCaster {
|
||||||
|
override val id: String = "chromecast"
|
||||||
|
|
||||||
|
override val devices: StateFlow<List<CastDevice>> = MutableStateFlow<List<CastDevice>>(emptyList()).asStateFlow()
|
||||||
|
|
||||||
|
override val sessionState: StateFlow<CastSessionState> = MutableStateFlow<CastSessionState>(CastSessionState.Idle).asStateFlow()
|
||||||
|
|
||||||
|
override fun startDiscovery() = Unit
|
||||||
|
|
||||||
|
override fun stopDiscovery() = Unit
|
||||||
|
|
||||||
|
override suspend fun cast(
|
||||||
|
device: CastDevice,
|
||||||
|
request: CastRequest,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override suspend fun stopCasting() = Unit
|
||||||
|
}
|
||||||
@@ -42,6 +42,11 @@
|
|||||||
(otherwise the wrapper waits ~30s for QUIC PTO before reconnecting). -->
|
(otherwise the wrapper waits ~30s for QUIC PTO before reconnecting). -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- DLNA SSDP discovery (multicast 239.255.255.250:1900) for the LAN cast
|
||||||
|
feature. Acquired only while the cast device picker is open. -->
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
|
||||||
<!-- Audio/Video Playback -->
|
<!-- Audio/Video Playback -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
|
|||||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||||
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
|
||||||
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
import com.vitorpamplona.amethyst.model.torState.TorRelayState
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
||||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||||
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
|
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
|
||||||
@@ -492,6 +493,14 @@ class AppModules(
|
|||||||
Nip95CacheFactory.new(appContext)
|
Nip95CacheFactory.new(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LAN cast registry — aggregates Chromecast (play flavor only) and DLNA
|
||||||
|
// discovery into a single device list. Discovery starts only when the
|
||||||
|
// picker dialog opens; idle by default to keep multicast traffic off.
|
||||||
|
val castRegistry: CastRegistry by lazy {
|
||||||
|
Log.d("AppModules", "CastRegistry Init")
|
||||||
|
CastRegistry(appContext, applicationIOScope, uiPrefs.value.castProtocol)
|
||||||
|
}
|
||||||
|
|
||||||
// local video cache with disk + memory
|
// local video cache with disk + memory
|
||||||
val videoCache: VideoCache by lazy {
|
val videoCache: VideoCache by lazy {
|
||||||
Log.d("AppModules", "VideoCache Init")
|
Log.d("AppModules", "VideoCache Init")
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class AccountSyncedSettings(
|
|||||||
)
|
)
|
||||||
val videoPlayer =
|
val videoPlayer =
|
||||||
AccountVideoPlayerPreferences(
|
AccountVideoPlayerPreferences(
|
||||||
MutableStateFlow(internalSettings.videoPlayer.buttonItems.toImmutableList()),
|
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toInternal(): AccountSyncedSettingsInternal =
|
fun toInternal(): AccountSyncedSettingsInternal =
|
||||||
@@ -150,7 +150,8 @@ class AccountSyncedSettings(
|
|||||||
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
|
security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList()
|
val newVideoPlayerButtonItems =
|
||||||
|
mergeWithDefaultVideoPlayerButtons(syncedSettingsInternal.videoPlayer.buttonItems).toImmutableList()
|
||||||
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
|
if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) {
|
||||||
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
|
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -75,6 +75,7 @@ enum class VideoPlayerAction {
|
|||||||
Share,
|
Share,
|
||||||
Download,
|
Download,
|
||||||
PictureInPicture,
|
PictureInPicture,
|
||||||
|
Cast,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -94,11 +95,22 @@ val DefaultVideoPlayerButtonItems =
|
|||||||
VideoPlayerButtonItem(VideoPlayerAction.Fullscreen, VideoButtonLocation.TopBar),
|
VideoPlayerButtonItem(VideoPlayerAction.Fullscreen, VideoButtonLocation.TopBar),
|
||||||
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
|
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
|
||||||
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
|
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
|
||||||
|
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
|
||||||
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
|
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
|
||||||
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
|
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
|
||||||
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
|
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Existing accounts have a button-items list serialized before some actions
|
||||||
|
// existed (e.g. Cast was added later). Append any default items the saved list
|
||||||
|
// is missing so new actions surface without forcing a settings reset — the
|
||||||
|
// user's existing order/locations for actions they already have are preserved.
|
||||||
|
fun mergeWithDefaultVideoPlayerButtons(saved: List<VideoPlayerButtonItem>): List<VideoPlayerButtonItem> {
|
||||||
|
val knownActions = saved.mapTo(mutableSetOf()) { it.action }
|
||||||
|
val missing = DefaultVideoPlayerButtonItems.filter { it.action !in knownActions }
|
||||||
|
return if (missing.isEmpty()) saved else saved + missing
|
||||||
|
}
|
||||||
|
|
||||||
fun getLanguagesSpokenByUser(): Set<String> {
|
fun getLanguagesSpokenByUser(): Set<String> {
|
||||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||||
val codedList = mutableSetOf<String>()
|
val codedList = mutableSetOf<String>()
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ data class UiSettings(
|
|||||||
val showHomeNewThreadsTab: Boolean = true,
|
val showHomeNewThreadsTab: Boolean = true,
|
||||||
val showHomeConversationsTab: Boolean = true,
|
val showHomeConversationsTab: Boolean = true,
|
||||||
val showHomeEverythingTab: Boolean = false,
|
val showHomeEverythingTab: Boolean = false,
|
||||||
|
val castProtocol: CastProtocolType = CastProtocolType.BOTH,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ThemeType(
|
enum class ThemeType(
|
||||||
@@ -147,6 +148,23 @@ fun parseBooleanType(screenCode: Int): BooleanType =
|
|||||||
else -> BooleanType.ALWAYS
|
else -> BooleanType.ALWAYS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class CastProtocolType(
|
||||||
|
val screenCode: Int,
|
||||||
|
val resourceId: Int,
|
||||||
|
) {
|
||||||
|
BOTH(0, R.string.cast_protocol_both),
|
||||||
|
CHROMECAST(1, R.string.cast_protocol_chromecast),
|
||||||
|
DLNA(2, R.string.cast_protocol_dlna),
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseCastProtocolType(screenCode: Int): CastProtocolType =
|
||||||
|
when (screenCode) {
|
||||||
|
CastProtocolType.BOTH.screenCode -> CastProtocolType.BOTH
|
||||||
|
CastProtocolType.CHROMECAST.screenCode -> CastProtocolType.CHROMECAST
|
||||||
|
CastProtocolType.DLNA.screenCode -> CastProtocolType.DLNA
|
||||||
|
else -> CastProtocolType.BOTH
|
||||||
|
}
|
||||||
|
|
||||||
enum class WarningType(
|
enum class WarningType(
|
||||||
val prefCode: Boolean?,
|
val prefCode: Boolean?,
|
||||||
val screenCode: Int,
|
val screenCode: Int,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class UiSettingsFlow(
|
|||||||
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||||
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||||
|
val castProtocol: MutableStateFlow<CastProtocolType> = MutableStateFlow(CastProtocolType.BOTH),
|
||||||
) {
|
) {
|
||||||
val listOfFlows: List<Flow<Any?>> =
|
val listOfFlows: List<Flow<Any?>> =
|
||||||
listOf<Flow<Any?>>(
|
listOf<Flow<Any?>>(
|
||||||
@@ -68,6 +69,7 @@ class UiSettingsFlow(
|
|||||||
showHomeNewThreadsTab,
|
showHomeNewThreadsTab,
|
||||||
showHomeConversationsTab,
|
showHomeConversationsTab,
|
||||||
showHomeEverythingTab,
|
showHomeEverythingTab,
|
||||||
|
castProtocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
// emits at every change in any of the propertyes.
|
// emits at every change in any of the propertyes.
|
||||||
@@ -93,6 +95,7 @@ class UiSettingsFlow(
|
|||||||
flows[15] as Boolean,
|
flows[15] as Boolean,
|
||||||
flows[16] as Boolean,
|
flows[16] as Boolean,
|
||||||
flows[17] as Boolean,
|
flows[17] as Boolean,
|
||||||
|
flows[18] as CastProtocolType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +119,7 @@ class UiSettingsFlow(
|
|||||||
showHomeNewThreadsTab.value,
|
showHomeNewThreadsTab.value,
|
||||||
showHomeConversationsTab.value,
|
showHomeConversationsTab.value,
|
||||||
showHomeEverythingTab.value,
|
showHomeEverythingTab.value,
|
||||||
|
castProtocol.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun update(torSettings: UiSettings): Boolean {
|
fun update(torSettings: UiSettings): Boolean {
|
||||||
@@ -193,6 +197,10 @@ class UiSettingsFlow(
|
|||||||
showHomeEverythingTab.tryEmit(torSettings.showHomeEverythingTab)
|
showHomeEverythingTab.tryEmit(torSettings.showHomeEverythingTab)
|
||||||
any = true
|
any = true
|
||||||
}
|
}
|
||||||
|
if (castProtocol.value != torSettings.castProtocol) {
|
||||||
|
castProtocol.tryEmit(torSettings.castProtocol)
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
|
||||||
return any
|
return any
|
||||||
}
|
}
|
||||||
@@ -230,6 +238,7 @@ class UiSettingsFlow(
|
|||||||
MutableStateFlow(uiSettings.showHomeNewThreadsTab),
|
MutableStateFlow(uiSettings.showHomeNewThreadsTab),
|
||||||
MutableStateFlow(uiSettings.showHomeConversationsTab),
|
MutableStateFlow(uiSettings.showHomeConversationsTab),
|
||||||
MutableStateFlow(uiSettings.showHomeEverythingTab),
|
MutableStateFlow(uiSettings.showHomeEverythingTab),
|
||||||
|
MutableStateFlow(uiSettings.castProtocol),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -32,6 +32,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.vitorpamplona.amethyst.LocalPreferences
|
import com.vitorpamplona.amethyst.LocalPreferences
|
||||||
import com.vitorpamplona.amethyst.model.BooleanType
|
import com.vitorpamplona.amethyst.model.BooleanType
|
||||||
|
import com.vitorpamplona.amethyst.model.CastProtocolType
|
||||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||||
import com.vitorpamplona.amethyst.model.ProfileGalleryType
|
import com.vitorpamplona.amethyst.model.ProfileGalleryType
|
||||||
@@ -108,6 +109,7 @@ class UiSharedPreferences(
|
|||||||
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
|
val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements")
|
||||||
val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts")
|
val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts")
|
||||||
val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items")
|
val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items")
|
||||||
|
val UI_CAST_PROTOCOL = stringPreferencesKey("ui.cast_protocol")
|
||||||
|
|
||||||
suspend fun uiPreferences(context: Context): UiSettings? =
|
suspend fun uiPreferences(context: Context): UiSettings? =
|
||||||
try {
|
try {
|
||||||
@@ -134,6 +136,7 @@ class UiSharedPreferences(
|
|||||||
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
|
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
|
||||||
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
|
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
|
||||||
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems,
|
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems,
|
||||||
|
castProtocol = preferences[UI_CAST_PROTOCOL]?.let { CastProtocolType.valueOf(it) } ?: CastProtocolType.BOTH,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
@@ -173,6 +176,7 @@ class UiSharedPreferences(
|
|||||||
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
|
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
|
||||||
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
|
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
|
||||||
preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name }
|
preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name }
|
||||||
|
preferences[UI_CAST_PROTOCOL] = sharedSettings.castProtocol.name
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.cast
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
enum class CastDeviceKind {
|
||||||
|
Chromecast,
|
||||||
|
Dlna,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class CastDevice(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val kind: CastDeviceKind,
|
||||||
|
val casterId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class CastRequest(
|
||||||
|
val url: String,
|
||||||
|
val mimeType: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val artworkUri: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// When the caller didn't supply a mime hint, fall back to a best-guess from the
|
||||||
|
// URL extension. Critical for HLS: the default Cast receiver crashes loading
|
||||||
|
// `.m3u8` content sent as `video/mp4` because it tries to demux a playlist as
|
||||||
|
// MP4 — wiping the TV's Cast service from the network in the process.
|
||||||
|
fun CastRequest.effectiveMimeType(): String =
|
||||||
|
mimeType?.takeIf { it.isNotBlank() }
|
||||||
|
?: when {
|
||||||
|
url.endsWith(".m3u8", ignoreCase = true) -> "application/vnd.apple.mpegurl"
|
||||||
|
url.endsWith(".mpd", ignoreCase = true) -> "application/dash+xml"
|
||||||
|
url.endsWith(".webm", ignoreCase = true) -> "video/webm"
|
||||||
|
url.endsWith(".mkv", ignoreCase = true) -> "video/x-matroska"
|
||||||
|
else -> "video/mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class CastSessionState {
|
||||||
|
object Idle : CastSessionState()
|
||||||
|
|
||||||
|
data class Connecting(
|
||||||
|
val device: CastDevice,
|
||||||
|
) : CastSessionState()
|
||||||
|
|
||||||
|
data class Casting(
|
||||||
|
val device: CastDevice,
|
||||||
|
val request: CastRequest,
|
||||||
|
) : CastSessionState()
|
||||||
|
|
||||||
|
data class Error(
|
||||||
|
val device: CastDevice?,
|
||||||
|
val message: String,
|
||||||
|
) : CastSessionState()
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.cast
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.vitorpamplona.amethyst.model.CastProtocolType
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.dlna.DlnaCaster
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
private const val TAG = "CastRegistry"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates every [VideoCaster] implementation available on this build into
|
||||||
|
* a single device list and a single active-session view, so the cast UI
|
||||||
|
* doesn't care whether a device speaks Chromecast or DLNA.
|
||||||
|
*
|
||||||
|
* Discovery is started/stopped explicitly by callers (typically the picker
|
||||||
|
* dialog's DisposableEffect) so that SSDP multicast traffic + the
|
||||||
|
* MulticastLock are only paid for while the user is choosing.
|
||||||
|
*
|
||||||
|
* The registry honours [protocolFlow]: when the user has narrowed cast to a
|
||||||
|
* single protocol, the disabled caster never starts discovery and its devices
|
||||||
|
* are filtered out of [devices]. Switching protocols mid-session leaves any
|
||||||
|
* active cast running — only future discovery is affected.
|
||||||
|
*/
|
||||||
|
class CastRegistry(
|
||||||
|
appContext: Context,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
private val protocolFlow: StateFlow<CastProtocolType> =
|
||||||
|
MutableStateFlow(CastProtocolType.BOTH),
|
||||||
|
) {
|
||||||
|
private val casters: List<VideoCaster> =
|
||||||
|
listOf(
|
||||||
|
ChromecastCaster(appContext),
|
||||||
|
DlnaCaster(appContext),
|
||||||
|
)
|
||||||
|
|
||||||
|
val devices: StateFlow<List<CastDevice>> =
|
||||||
|
combine(
|
||||||
|
combine(casters.map { it.devices }) { snapshots -> snapshots.toList() },
|
||||||
|
protocolFlow,
|
||||||
|
) { snapshots, proto ->
|
||||||
|
val merged =
|
||||||
|
snapshots.flatMapIndexed { index, list ->
|
||||||
|
if (isCasterEnabled(casters[index], proto)) list else emptyList()
|
||||||
|
}
|
||||||
|
Log.d(TAG) {
|
||||||
|
"devices flow update proto=$proto " +
|
||||||
|
snapshots.mapIndexed { i, l -> "${casters[i].id}=${l.size}" }.joinToString() +
|
||||||
|
" merged=${merged.size} -> [${merged.joinToString { "${it.casterId}:${it.name}" }}]"
|
||||||
|
}
|
||||||
|
merged
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
val sessionState: StateFlow<CastSessionState> =
|
||||||
|
combine(casters.map { it.sessionState }) { states ->
|
||||||
|
states.firstOrNull { it !is CastSessionState.Idle } ?: CastSessionState.Idle
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, CastSessionState.Idle)
|
||||||
|
|
||||||
|
private val refCount = AtomicInteger(0)
|
||||||
|
private val active =
|
||||||
|
ConcurrentHashMap<String, Boolean>().apply {
|
||||||
|
casters.forEach { put(it.id, false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
Log.d(TAG) {
|
||||||
|
"init casters=[${casters.joinToString { it.id }}] initialProtocol=${protocolFlow.value}"
|
||||||
|
}
|
||||||
|
// While the picker is open, react to protocol changes by stopping/starting
|
||||||
|
// the affected casters. Idle case is a no-op because reconcile only runs
|
||||||
|
// when refCount > 0.
|
||||||
|
scope.launch {
|
||||||
|
protocolFlow.collect { proto ->
|
||||||
|
Log.d(TAG) { "protocolFlow change -> $proto refCount=${refCount.get()}" }
|
||||||
|
if (refCount.get() > 0) reconcile()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCasterEnabled(
|
||||||
|
caster: VideoCaster,
|
||||||
|
proto: CastProtocolType,
|
||||||
|
): Boolean =
|
||||||
|
when (proto) {
|
||||||
|
CastProtocolType.BOTH -> true
|
||||||
|
CastProtocolType.CHROMECAST -> caster.id == "chromecast"
|
||||||
|
CastProtocolType.DLNA -> caster.id == "dlna"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun reconcile() {
|
||||||
|
val proto = protocolFlow.value
|
||||||
|
casters.forEach { caster ->
|
||||||
|
val shouldRun = isCasterEnabled(caster, proto)
|
||||||
|
val running = active[caster.id] == true
|
||||||
|
if (shouldRun && !running) {
|
||||||
|
Log.d(TAG) { "reconcile START ${caster.id} (proto=$proto)" }
|
||||||
|
runCatching { caster.startDiscovery() }
|
||||||
|
.onFailure { Log.w(TAG, "reconcile START ${caster.id} threw: ${it.message}", it) }
|
||||||
|
active[caster.id] = true
|
||||||
|
} else if (!shouldRun && running) {
|
||||||
|
Log.d(TAG) { "reconcile STOP ${caster.id} (proto=$proto)" }
|
||||||
|
runCatching { caster.stopDiscovery() }
|
||||||
|
.onFailure { Log.w(TAG, "reconcile STOP ${caster.id} threw: ${it.message}", it) }
|
||||||
|
active[caster.id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun stopAll() {
|
||||||
|
Log.d(TAG) { "stopAll active=${active.filterValues { it }.keys}" }
|
||||||
|
casters.forEach { caster ->
|
||||||
|
if (active[caster.id] == true) {
|
||||||
|
runCatching { caster.stopDiscovery() }
|
||||||
|
.onFailure { Log.w(TAG, "stopAll ${caster.id} threw: ${it.message}", it) }
|
||||||
|
active[caster.id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startDiscovery() {
|
||||||
|
val before = refCount.getAndIncrement()
|
||||||
|
Log.d(TAG) { "startDiscovery refCount $before -> ${before + 1}" }
|
||||||
|
if (before == 0) reconcile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopDiscovery() {
|
||||||
|
val after = refCount.decrementAndGet()
|
||||||
|
Log.d(TAG) { "stopDiscovery refCount -> $after" }
|
||||||
|
if (after <= 0) {
|
||||||
|
refCount.set(0)
|
||||||
|
stopAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cast(
|
||||||
|
device: CastDevice,
|
||||||
|
request: CastRequest,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) {
|
||||||
|
"cast device=${device.casterId}:${device.name} url=${request.url} mime=${request.mimeType}"
|
||||||
|
}
|
||||||
|
val caster = casters.firstOrNull { it.id == device.casterId }
|
||||||
|
if (caster == null) {
|
||||||
|
Log.w(TAG, "cast: no caster found for casterId=${device.casterId}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caster.cast(device, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stopCasting() {
|
||||||
|
Log.d(TAG) { "stopCasting (broadcast to all casters)" }
|
||||||
|
casters.forEach { runCatching { it.stopCasting() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.cast
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common contract for any LAN cast protocol that Amethyst can drive — currently
|
||||||
|
* Chromecast (Google Cast SDK, play flavor only) and DLNA / UPnP AVTransport
|
||||||
|
* (jUPnP, both flavors). The registry layer aggregates one or more of these
|
||||||
|
* to give the UI a unified device list and active-session state.
|
||||||
|
*/
|
||||||
|
interface VideoCaster {
|
||||||
|
/** Stable identifier for diagnostics and merging device IDs across casters. */
|
||||||
|
val id: String
|
||||||
|
|
||||||
|
/** Discovered receiver devices; emits a snapshot whenever the set changes. */
|
||||||
|
val devices: StateFlow<List<CastDevice>>
|
||||||
|
|
||||||
|
/** Status of the active casting session owned by this caster. */
|
||||||
|
val sessionState: StateFlow<CastSessionState>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the registry while at least one observer wants device updates
|
||||||
|
* (typically: the picker dialog is open, or a session is active).
|
||||||
|
* Implementations should be idempotent.
|
||||||
|
*/
|
||||||
|
fun startDiscovery()
|
||||||
|
|
||||||
|
/** Counterpart to [startDiscovery]. Idempotent; no-op when not running. */
|
||||||
|
fun stopDiscovery()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand a video off to [device] and start playback. Updates [sessionState]
|
||||||
|
* to Connecting → Casting (or Error). [device] must be one this caster
|
||||||
|
* emitted in [devices] — the registry routes by [CastDevice.casterId].
|
||||||
|
*/
|
||||||
|
suspend fun cast(
|
||||||
|
device: CastDevice,
|
||||||
|
request: CastRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** End the active session if any. Idempotent. */
|
||||||
|
suspend fun stopCasting()
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.cast.dlna
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastDevice
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastDeviceKind
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.VideoCaster
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.effectiveMimeType
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.jupnp.UpnpService
|
||||||
|
import org.jupnp.UpnpServiceImpl
|
||||||
|
import org.jupnp.android.AndroidUpnpServiceConfiguration
|
||||||
|
import org.jupnp.model.action.ActionInvocation
|
||||||
|
import org.jupnp.model.message.UpnpResponse
|
||||||
|
import org.jupnp.model.message.header.STAllHeader
|
||||||
|
import org.jupnp.model.meta.LocalDevice
|
||||||
|
import org.jupnp.model.meta.RemoteDevice
|
||||||
|
import org.jupnp.model.types.UDADeviceType
|
||||||
|
import org.jupnp.model.types.UDAServiceType
|
||||||
|
import org.jupnp.model.types.UDN
|
||||||
|
import org.jupnp.registry.Registry
|
||||||
|
import org.jupnp.registry.RegistryListener
|
||||||
|
import org.jupnp.support.avtransport.callback.Play
|
||||||
|
import org.jupnp.support.avtransport.callback.SetAVTransportURI
|
||||||
|
import org.jupnp.support.avtransport.callback.Stop
|
||||||
|
import org.jupnp.support.contentdirectory.DIDLParser
|
||||||
|
import org.jupnp.support.model.DIDLContent
|
||||||
|
import org.jupnp.support.model.ProtocolInfo
|
||||||
|
import org.jupnp.support.model.Res
|
||||||
|
import org.jupnp.support.model.item.VideoItem
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
private const val TAG = "DlnaCaster"
|
||||||
|
|
||||||
|
private val MEDIA_RENDERER = UDADeviceType("MediaRenderer")
|
||||||
|
private val AV_TRANSPORT = UDAServiceType("AVTransport")
|
||||||
|
|
||||||
|
class DlnaCaster(
|
||||||
|
private val appContext: Context,
|
||||||
|
) : VideoCaster {
|
||||||
|
override val id: String = "dlna"
|
||||||
|
|
||||||
|
private val devicesFlow = MutableStateFlow<List<CastDevice>>(emptyList())
|
||||||
|
override val devices: StateFlow<List<CastDevice>> = devicesFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val sessionFlow = MutableStateFlow<CastSessionState>(CastSessionState.Idle)
|
||||||
|
override val sessionState: StateFlow<CastSessionState> = sessionFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val knownDevices = ConcurrentHashMap<String, RemoteDevice>()
|
||||||
|
private var multicastLock: WifiManager.MulticastLock? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var upnpService: UpnpService? = null
|
||||||
|
|
||||||
|
private val listener =
|
||||||
|
object : RegistryListener {
|
||||||
|
override fun remoteDeviceDiscoveryStarted(
|
||||||
|
registry: Registry,
|
||||||
|
device: RemoteDevice,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "discoveryStarted udn=${device.identity.udn} type=${device.type}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun remoteDeviceDiscoveryFailed(
|
||||||
|
registry: Registry,
|
||||||
|
device: RemoteDevice,
|
||||||
|
ex: Exception?,
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "discoveryFailed udn=${device.identity.udn} type=${device.type} ex=${ex?.message}", ex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun remoteDeviceAdded(
|
||||||
|
registry: Registry,
|
||||||
|
device: RemoteDevice,
|
||||||
|
) {
|
||||||
|
val isRenderer = device.type.implementsVersion(MEDIA_RENDERER)
|
||||||
|
Log.d(TAG) {
|
||||||
|
"remoteDeviceAdded udn=${device.identity.udn} type=${device.type} renderer=$isRenderer name=${device.details?.friendlyName}"
|
||||||
|
}
|
||||||
|
if (isRenderer) addDevice(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun remoteDeviceUpdated(
|
||||||
|
registry: Registry,
|
||||||
|
device: RemoteDevice,
|
||||||
|
) {
|
||||||
|
if (device.type.implementsVersion(MEDIA_RENDERER)) addDevice(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun remoteDeviceRemoved(
|
||||||
|
registry: Registry,
|
||||||
|
device: RemoteDevice,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "remoteDeviceRemoved udn=${device.identity.udn}" }
|
||||||
|
removeDevice(device.identity.udn.identifierString)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun localDeviceAdded(
|
||||||
|
registry: Registry,
|
||||||
|
device: LocalDevice,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun localDeviceRemoved(
|
||||||
|
registry: Registry,
|
||||||
|
device: LocalDevice,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun beforeShutdown(registry: Registry) = Unit
|
||||||
|
|
||||||
|
override fun afterShutdown() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addDevice(device: RemoteDevice) {
|
||||||
|
val udn = device.identity.udn.identifierString
|
||||||
|
knownDevices[udn] = device
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeDevice(udn: String) {
|
||||||
|
knownDevices.remove(udn)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
devicesFlow.value =
|
||||||
|
knownDevices.values.map { device ->
|
||||||
|
CastDevice(
|
||||||
|
id = device.identity.udn.identifierString,
|
||||||
|
name = device.details?.friendlyName ?: device.displayString,
|
||||||
|
kind = CastDeviceKind.Dlna,
|
||||||
|
casterId = id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun startDiscovery() {
|
||||||
|
Log.d(TAG) { "startDiscovery (already running? ${upnpService != null})" }
|
||||||
|
if (upnpService != null) return
|
||||||
|
try {
|
||||||
|
acquireMulticastLock()
|
||||||
|
val service = UpnpServiceImpl(AndroidUpnpServiceConfiguration())
|
||||||
|
service.startup()
|
||||||
|
service.registry.addListener(listener)
|
||||||
|
// Republish anything already in the registry (covers reuse).
|
||||||
|
val preexisting =
|
||||||
|
service.registry.remoteDevices
|
||||||
|
.filter { it.type.implementsVersion(MEDIA_RENDERER) }
|
||||||
|
Log.d(TAG) { "startDiscovery: preexisting renderers=${preexisting.size}" }
|
||||||
|
preexisting.forEach { addDevice(it) }
|
||||||
|
service.controlPoint.search(STAllHeader())
|
||||||
|
upnpService = service
|
||||||
|
Log.d(TAG) { "startDiscovery: jUPnP started, SSDP M-SEARCH sent (multicastLock held=${multicastLock?.isHeld})" }
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "startDiscovery failed: ${t.message}", t)
|
||||||
|
releaseMulticastLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
override fun stopDiscovery() {
|
||||||
|
Log.d(TAG) { "stopDiscovery (running? ${upnpService != null})" }
|
||||||
|
val service = upnpService ?: return
|
||||||
|
try {
|
||||||
|
service.registry.removeListener(listener)
|
||||||
|
service.shutdown()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "stopDiscovery failed: ${t.message}", t)
|
||||||
|
}
|
||||||
|
upnpService = null
|
||||||
|
knownDevices.clear()
|
||||||
|
publish()
|
||||||
|
releaseMulticastLock()
|
||||||
|
Log.d(TAG) { "stopDiscovery: jUPnP stopped, multicast lock released" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acquireMulticastLock() {
|
||||||
|
if (multicastLock != null) return
|
||||||
|
try {
|
||||||
|
val wifi = appContext.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
if (wifi == null) {
|
||||||
|
Log.w(TAG, "acquireMulticastLock: WifiManager unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lock = wifi.createMulticastLock("amethyst-dlna-cast")
|
||||||
|
lock.setReferenceCounted(false)
|
||||||
|
lock.acquire()
|
||||||
|
multicastLock = lock
|
||||||
|
Log.d(TAG) { "acquireMulticastLock: held=${lock.isHeld}" }
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "acquireMulticastLock failed: ${t.message}", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseMulticastLock() {
|
||||||
|
try {
|
||||||
|
multicastLock?.takeIf { it.isHeld }?.release()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "releaseMulticastLock failed: ${t.message}", t)
|
||||||
|
}
|
||||||
|
multicastLock = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cast(
|
||||||
|
device: CastDevice,
|
||||||
|
request: CastRequest,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "cast device=${device.name} udn=${device.id} url=${request.url}" }
|
||||||
|
val service = upnpService
|
||||||
|
if (service == null) {
|
||||||
|
Log.w(TAG, "cast: jUPnP service not running")
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "DLNA service not running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val remoteDevice = knownDevices[device.id] ?: service.registry.getRemoteDevice(UDN.valueOf(device.id), true)
|
||||||
|
if (remoteDevice == null) {
|
||||||
|
Log.w(TAG, "cast: device ${device.id} not in registry")
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "Device went offline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val avTransport = remoteDevice.findService(AV_TRANSPORT)
|
||||||
|
if (avTransport == null) {
|
||||||
|
Log.w(TAG, "cast: device ${device.id} has no AVTransport service")
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "Device does not advertise AVTransport")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionFlow.value = CastSessionState.Connecting(device)
|
||||||
|
|
||||||
|
val metadata = buildDidlMetadata(request)
|
||||||
|
Log.d(TAG) { "cast: SetAVTransportURI -> ${request.url} (didl=${metadata.length}b)" }
|
||||||
|
|
||||||
|
val setUriDone = CompletableDeferred<Boolean>()
|
||||||
|
service.controlPoint.execute(
|
||||||
|
object : SetAVTransportURI(avTransport, request.url, metadata) {
|
||||||
|
override fun success(invocation: ActionInvocation<*>?) {
|
||||||
|
setUriDone.complete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun failure(
|
||||||
|
invocation: ActionInvocation<*>?,
|
||||||
|
operation: UpnpResponse?,
|
||||||
|
defaultMsg: String?,
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "SetAVTransportURI failed: $defaultMsg")
|
||||||
|
setUriDone.complete(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val uriOk = setUriDone.await()
|
||||||
|
if (!uriOk) {
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "Receiver rejected the media URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val playDone = CompletableDeferred<Boolean>()
|
||||||
|
service.controlPoint.execute(
|
||||||
|
object : Play(avTransport) {
|
||||||
|
override fun success(invocation: ActionInvocation<*>?) {
|
||||||
|
playDone.complete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun failure(
|
||||||
|
invocation: ActionInvocation<*>?,
|
||||||
|
operation: UpnpResponse?,
|
||||||
|
defaultMsg: String?,
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Play failed: $defaultMsg")
|
||||||
|
playDone.complete(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (playDone.await()) {
|
||||||
|
Log.d(TAG) { "cast: Play succeeded" }
|
||||||
|
sessionFlow.value = CastSessionState.Casting(device, request)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "cast: Play action rejected by receiver")
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "Receiver rejected Play action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopCasting() {
|
||||||
|
Log.d(TAG) { "stopCasting (running? ${upnpService != null}, state=${sessionFlow.value::class.simpleName})" }
|
||||||
|
val service =
|
||||||
|
upnpService ?: run {
|
||||||
|
sessionFlow.value = CastSessionState.Idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val current = sessionFlow.value
|
||||||
|
val device =
|
||||||
|
when (current) {
|
||||||
|
is CastSessionState.Casting -> {
|
||||||
|
current.device
|
||||||
|
}
|
||||||
|
|
||||||
|
is CastSessionState.Connecting -> {
|
||||||
|
current.device
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
sessionFlow.value = CastSessionState.Idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val remoteDevice = knownDevices[device.id]
|
||||||
|
val avTransport = remoteDevice?.findService(AV_TRANSPORT)
|
||||||
|
if (avTransport != null) {
|
||||||
|
val done = CompletableDeferred<Unit>()
|
||||||
|
service.controlPoint.execute(
|
||||||
|
object : Stop(avTransport) {
|
||||||
|
override fun success(invocation: ActionInvocation<*>?) {
|
||||||
|
done.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun failure(
|
||||||
|
invocation: ActionInvocation<*>?,
|
||||||
|
operation: UpnpResponse?,
|
||||||
|
defaultMsg: String?,
|
||||||
|
) {
|
||||||
|
done.complete(Unit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
done.await()
|
||||||
|
}
|
||||||
|
sessionFlow.value = CastSessionState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDidlMetadata(request: CastRequest): String =
|
||||||
|
try {
|
||||||
|
val mime = request.effectiveMimeType()
|
||||||
|
val res = Res(ProtocolInfo("http-get:*:$mime:*"), null as Long?, request.url)
|
||||||
|
val item =
|
||||||
|
VideoItem(
|
||||||
|
// id =
|
||||||
|
"amethyst-cast-1",
|
||||||
|
// parentID =
|
||||||
|
"0",
|
||||||
|
// title =
|
||||||
|
request.title ?: "Amethyst",
|
||||||
|
// creator =
|
||||||
|
"Amethyst",
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
val didl = DIDLContent()
|
||||||
|
didl.addItem(item)
|
||||||
|
DIDLParser().generate(didl)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "Could not build DIDL metadata, sending empty: ${t.message}", t)
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -70,6 +70,7 @@ fun OverflowMenuButtonPreview() {
|
|||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
onSaveClick = {},
|
onSaveClick = {},
|
||||||
onPipClick = {},
|
onPipClick = {},
|
||||||
|
onCastClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,6 +87,7 @@ fun AnimatedOverflowMenuButton(
|
|||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
onSaveClick: () -> Unit,
|
onSaveClick: () -> Unit,
|
||||||
onPipClick: () -> Unit,
|
onPipClick: () -> Unit,
|
||||||
|
onCastClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -103,6 +105,7 @@ fun AnimatedOverflowMenuButton(
|
|||||||
onShareClick = onShareClick,
|
onShareClick = onShareClick,
|
||||||
onSaveClick = onSaveClick,
|
onSaveClick = onSaveClick,
|
||||||
onPipClick = onPipClick,
|
onPipClick = onPipClick,
|
||||||
|
onCastClick = onCastClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,7 @@ fun OverflowMenuButton(
|
|||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
onSaveClick: () -> Unit,
|
onSaveClick: () -> Unit,
|
||||||
onPipClick: () -> Unit,
|
onPipClick: () -> Unit,
|
||||||
|
onCastClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val menuExpanded = remember { mutableStateOf(false) }
|
val menuExpanded = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -211,6 +215,16 @@ fun OverflowMenuButton(
|
|||||||
onPipClick()
|
onPipClick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VideoPlayerAction.Cast -> {
|
||||||
|
M3ActionRow(
|
||||||
|
icon = MaterialSymbols.Cast,
|
||||||
|
text = stringRes(R.string.cast_to_device),
|
||||||
|
) {
|
||||||
|
menuExpanded.value = false
|
||||||
|
onCastClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -55,11 +55,13 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
|||||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||||
import com.vitorpamplona.amethyst.model.VideoButtonLocation
|
import com.vitorpamplona.amethyst.model.VideoButtonLocation
|
||||||
import com.vitorpamplona.amethyst.model.VideoPlayerAction
|
import com.vitorpamplona.amethyst.model.VideoPlayerAction
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
|
||||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||||
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity
|
||||||
|
import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog
|
||||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@@ -197,6 +199,7 @@ fun RenderTopButtons(
|
|||||||
) {
|
) {
|
||||||
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
|
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
|
||||||
val shareDialogVisible = remember { mutableStateOf(false) }
|
val shareDialogVisible = remember { mutableStateOf(false) }
|
||||||
|
val castDialogVisible = remember { mutableStateOf(false) }
|
||||||
val saveAction =
|
val saveAction =
|
||||||
rememberSaveMediaAction { context ->
|
rememberSaveMediaAction { context ->
|
||||||
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context)
|
||||||
@@ -210,6 +213,7 @@ fun RenderTopButtons(
|
|||||||
VideoPlayerAction.Share -> true
|
VideoPlayerAction.Share -> true
|
||||||
VideoPlayerAction.Download -> !isLive
|
VideoPlayerAction.Download -> !isLive
|
||||||
VideoPlayerAction.PictureInPicture -> pipSupported
|
VideoPlayerAction.PictureInPicture -> pipSupported
|
||||||
|
VideoPlayerAction.Cast -> mediaData.videoUri.startsWith("http", ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val canFullscreen = onZoomClick != null
|
val canFullscreen = onZoomClick != null
|
||||||
@@ -281,6 +285,15 @@ fun RenderTopButtons(
|
|||||||
onClick = onPictureInPictureClick,
|
onClick = onPictureInPictureClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VideoPlayerAction.Cast -> {
|
||||||
|
AnimatedTopBarIconButton(
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
symbol = MaterialSymbols.Cast,
|
||||||
|
contentDescription = stringRes(R.string.cast_to_device),
|
||||||
|
onClick = { castDialogVisible.value = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +308,20 @@ fun RenderTopButtons(
|
|||||||
onShareClick = { shareDialogVisible.value = true },
|
onShareClick = { shareDialogVisible.value = true },
|
||||||
onSaveClick = saveAction,
|
onSaveClick = saveAction,
|
||||||
onPipClick = onPictureInPictureClick,
|
onPipClick = onPictureInPictureClick,
|
||||||
|
onCastClick = { castDialogVisible.value = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (castDialogVisible.value) {
|
||||||
|
CastDevicePickerDialog(
|
||||||
|
request =
|
||||||
|
CastRequest(
|
||||||
|
url = mediaData.videoUri,
|
||||||
|
mimeType = mediaData.mimeType,
|
||||||
|
title = mediaData.title,
|
||||||
|
artworkUri = mediaData.artworkUri,
|
||||||
|
),
|
||||||
|
onDismiss = { castDialogVisible.value = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.cast
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastDeviceKind
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastRegistry
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val TAG = "CastPicker"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CastDevicePickerDialog(
|
||||||
|
request: CastRequest,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
registry: CastRegistry = Amethyst.instance.castRegistry,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "open url=${request.url} mime=${request.mimeType}" }
|
||||||
|
LaunchedEffect(registry) {
|
||||||
|
registry.startDiscovery()
|
||||||
|
}
|
||||||
|
androidx.compose.runtime.DisposableEffect(registry) {
|
||||||
|
onDispose {
|
||||||
|
Log.d(TAG) { "dispose -> stopDiscovery" }
|
||||||
|
registry.stopDiscovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val devices by registry.devices.collectAsStateWithLifecycle()
|
||||||
|
val sessionState by registry.sessionState.collectAsStateWithLifecycle()
|
||||||
|
// Application-scoped so the cast survives dialog dismissal — the dialog
|
||||||
|
// dismisses ~10ms after the device tap, which would cancel a composition-
|
||||||
|
// scoped coroutine before remoteMediaClient.load() could run, leaving the
|
||||||
|
// receiver showing the default-receiver splash with no media loaded.
|
||||||
|
val castScope = Amethyst.instance.applicationIOScope
|
||||||
|
|
||||||
|
M3ActionDialog(
|
||||||
|
title = stringRes(R.string.cast_to_device_dialog_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
if (devices.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.cast_searching_for_devices),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
M3ActionSection {
|
||||||
|
devices.forEach { device ->
|
||||||
|
val icon =
|
||||||
|
when (device.kind) {
|
||||||
|
CastDeviceKind.Chromecast -> MaterialSymbols.Cast
|
||||||
|
CastDeviceKind.Dlna -> MaterialSymbols.CastConnected
|
||||||
|
}
|
||||||
|
M3ActionRow(
|
||||||
|
icon = icon,
|
||||||
|
text = device.name,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "tap device=${device.casterId}:${device.name}" }
|
||||||
|
// Keep discovery alive across the dialog's onDispose so the caster's
|
||||||
|
// session listener stays registered until the cast attempt finishes.
|
||||||
|
// Without this the dialog's stopDiscovery (~10ms after tap) tears the
|
||||||
|
// SessionManagerListener down before onSessionStarted fires.
|
||||||
|
registry.startDiscovery()
|
||||||
|
castScope.launch {
|
||||||
|
try {
|
||||||
|
registry.cast(device, request)
|
||||||
|
} finally {
|
||||||
|
registry.stopDiscovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionState is CastSessionState.Error) {
|
||||||
|
Text(
|
||||||
|
text = (sessionState as CastSessionState.Error).message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -53,14 +53,17 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.BuildConfig
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.BooleanType
|
import com.vitorpamplona.amethyst.model.BooleanType
|
||||||
|
import com.vitorpamplona.amethyst.model.CastProtocolType
|
||||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||||
import com.vitorpamplona.amethyst.model.ProfileGalleryType
|
import com.vitorpamplona.amethyst.model.ProfileGalleryType
|
||||||
import com.vitorpamplona.amethyst.model.ThemeType
|
import com.vitorpamplona.amethyst.model.ThemeType
|
||||||
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
||||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||||
|
import com.vitorpamplona.amethyst.model.parseCastProtocolType
|
||||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||||
import com.vitorpamplona.amethyst.model.parseFeatureSetType
|
import com.vitorpamplona.amethyst.model.parseFeatureSetType
|
||||||
import com.vitorpamplona.amethyst.model.parseGalleryType
|
import com.vitorpamplona.amethyst.model.parseGalleryType
|
||||||
@@ -128,6 +131,9 @@ fun SettingsScreen(
|
|||||||
ShowImagePreviewChoice(sharedPrefs)
|
ShowImagePreviewChoice(sharedPrefs)
|
||||||
ShowVideoPlaybackChoice(sharedPrefs)
|
ShowVideoPlaybackChoice(sharedPrefs)
|
||||||
AutoplayVideosChoice(sharedPrefs)
|
AutoplayVideosChoice(sharedPrefs)
|
||||||
|
if (BuildConfig.FLAVOR == "play") {
|
||||||
|
CastProtocolChoice(sharedPrefs)
|
||||||
|
}
|
||||||
ShowUrlPreviewChoice(sharedPrefs)
|
ShowUrlPreviewChoice(sharedPrefs)
|
||||||
ShowProfilePictureChoice(sharedPrefs)
|
ShowProfilePictureChoice(sharedPrefs)
|
||||||
ImmersiveScrollingChoice(sharedPrefs)
|
ImmersiveScrollingChoice(sharedPrefs)
|
||||||
@@ -280,6 +286,27 @@ fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CastProtocolChoice(sharedPrefs: UiSettingsFlow) {
|
||||||
|
val castProtocolOptions =
|
||||||
|
persistentListOf(
|
||||||
|
TitleExplainer(stringRes(CastProtocolType.BOTH.resourceId)),
|
||||||
|
TitleExplainer(stringRes(CastProtocolType.CHROMECAST.resourceId)),
|
||||||
|
TitleExplainer(stringRes(CastProtocolType.DLNA.resourceId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val castProtocolIndex by sharedPrefs.castProtocol.collectAsState()
|
||||||
|
|
||||||
|
SettingsRow(
|
||||||
|
R.string.cast_protocol_setting_title,
|
||||||
|
R.string.cast_protocol_setting_description,
|
||||||
|
castProtocolOptions,
|
||||||
|
castProtocolIndex.screenCode,
|
||||||
|
) {
|
||||||
|
sharedPrefs.castProtocol.tryEmit(parseCastProtocolType(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) {
|
fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) {
|
||||||
val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState()
|
val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState()
|
||||||
|
|||||||
+2
@@ -330,6 +330,7 @@ fun videoPlayerActionName(action: VideoPlayerAction): String =
|
|||||||
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share)
|
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share)
|
||||||
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download)
|
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download)
|
||||||
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip)
|
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip)
|
||||||
|
VideoPlayerAction.Cast -> stringRes(R.string.video_player_settings_action_cast)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -341,4 +342,5 @@ fun videoPlayerActionDescription(action: VideoPlayerAction): String =
|
|||||||
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share_description)
|
VideoPlayerAction.Share -> stringRes(R.string.video_player_settings_action_share_description)
|
||||||
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download_description)
|
VideoPlayerAction.Download -> stringRes(R.string.video_player_settings_action_download_description)
|
||||||
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip_description)
|
VideoPlayerAction.PictureInPicture -> stringRes(R.string.video_player_settings_action_pip_description)
|
||||||
|
VideoPlayerAction.Cast -> stringRes(R.string.video_player_settings_action_cast_description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1887,6 +1887,8 @@
|
|||||||
<string name="video_player_settings_action_download_description">Download the video to your device (hidden on live streams)</string>
|
<string name="video_player_settings_action_download_description">Download the video to your device (hidden on live streams)</string>
|
||||||
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
|
<string name="video_player_settings_action_pip">Picture-in-Picture</string>
|
||||||
<string name="video_player_settings_action_pip_description">Play the video in a floating window (hidden if unsupported)</string>
|
<string name="video_player_settings_action_pip_description">Play the video in a floating window (hidden if unsupported)</string>
|
||||||
|
<string name="video_player_settings_action_cast">Cast to Device</string>
|
||||||
|
<string name="video_player_settings_action_cast_description">Send the video to a Chromecast or DLNA receiver on your Wi-Fi (hidden for local files)</string>
|
||||||
|
|
||||||
<string name="profile_image_of_user">Profile Picture of %1$s</string>
|
<string name="profile_image_of_user">Profile Picture of %1$s</string>
|
||||||
<string name="relay_info">Relay %1$s</string>
|
<string name="relay_info">Relay %1$s</string>
|
||||||
@@ -2555,6 +2557,16 @@
|
|||||||
<string name="playback_actions_dialog_title">Playback</string>
|
<string name="playback_actions_dialog_title">Playback</string>
|
||||||
<string name="video_quality_auto">Auto</string>
|
<string name="video_quality_auto">Auto</string>
|
||||||
|
|
||||||
|
<!-- LAN cast (Chromecast / DLNA) feature -->
|
||||||
|
<string name="cast_to_device">Cast to device</string>
|
||||||
|
<string name="cast_to_device_dialog_title">Cast to…</string>
|
||||||
|
<string name="cast_searching_for_devices">Searching for devices on your Wi-Fi…</string>
|
||||||
|
<string name="cast_protocol_setting_title">Cast protocol</string>
|
||||||
|
<string name="cast_protocol_setting_description">Which devices to scan for when casting videos on your Wi-Fi</string>
|
||||||
|
<string name="cast_protocol_both">Chromecast & UPnP</string>
|
||||||
|
<string name="cast_protocol_chromecast">Chromecast only</string>
|
||||||
|
<string name="cast_protocol_dlna">UPnP / DLNA only</string>
|
||||||
|
|
||||||
<!-- HLS multi-resolution video sharing -->
|
<!-- HLS multi-resolution video sharing -->
|
||||||
<string name="share_hls_video">HLS Upload</string>
|
<string name="share_hls_video">HLS Upload</string>
|
||||||
<string name="share_hls_video_drawer_description">Publish multi-resolution HLS to your media server</string>
|
<string name="share_hls_video_drawer_description">Publish multi-resolution HLS to your media server</string>
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||||
android:value="@string/app_notification_channel_id" />
|
android:value="@string/app_notification_channel_id" />
|
||||||
|
|
||||||
|
<!-- Google Cast SDK options provider. Only declared in the play
|
||||||
|
flavor because the SDK hard-depends on Google Play services. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
|
android:value="com.vitorpamplona.amethyst.service.cast.chromecast.AmethystCastOptionsProvider" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.cast.chromecast
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.android.gms.cast.CastMediaControlIntent
|
||||||
|
import com.google.android.gms.cast.framework.CastOptions
|
||||||
|
import com.google.android.gms.cast.framework.OptionsProvider
|
||||||
|
import com.google.android.gms.cast.framework.SessionProvider
|
||||||
|
|
||||||
|
class AmethystCastOptionsProvider : OptionsProvider {
|
||||||
|
override fun getCastOptions(context: Context): CastOptions =
|
||||||
|
CastOptions
|
||||||
|
.Builder()
|
||||||
|
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
|
||||||
|
}
|
||||||
+410
@@ -0,0 +1,410 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.cast.chromecast
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.mediarouter.media.MediaRouteSelector
|
||||||
|
import androidx.mediarouter.media.MediaRouter
|
||||||
|
import com.google.android.gms.cast.CastMediaControlIntent
|
||||||
|
import com.google.android.gms.cast.MediaInfo
|
||||||
|
import com.google.android.gms.cast.MediaLoadRequestData
|
||||||
|
import com.google.android.gms.cast.MediaMetadata
|
||||||
|
import com.google.android.gms.cast.framework.CastContext
|
||||||
|
import com.google.android.gms.cast.framework.CastSession
|
||||||
|
import com.google.android.gms.cast.framework.SessionManagerListener
|
||||||
|
import com.google.android.gms.common.ConnectionResult
|
||||||
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
|
import com.google.android.gms.common.images.WebImage
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastDevice
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastDeviceKind
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.VideoCaster
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.effectiveMimeType
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
private const val TAG = "ChromecastCaster"
|
||||||
|
private const val SESSION_START_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Cast (Chromecast) implementation of [VideoCaster].
|
||||||
|
*
|
||||||
|
* Only present in the play flavor — the F-Droid flavor ships a no-op stub
|
||||||
|
* with the same FQN. The class still works at runtime when Google Play
|
||||||
|
* services are unavailable: discovery returns an empty list and casts fail
|
||||||
|
* cleanly.
|
||||||
|
*/
|
||||||
|
class ChromecastCaster(
|
||||||
|
private val appContext: Context,
|
||||||
|
) : VideoCaster {
|
||||||
|
override val id: String = "chromecast"
|
||||||
|
|
||||||
|
private val devicesFlow = MutableStateFlow<List<CastDevice>>(emptyList())
|
||||||
|
override val devices: StateFlow<List<CastDevice>> = devicesFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val sessionFlow = MutableStateFlow<CastSessionState>(CastSessionState.Idle)
|
||||||
|
override val sessionState: StateFlow<CastSessionState> = sessionFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private var mediaRouter: MediaRouter? = null
|
||||||
|
private var routeSelector: MediaRouteSelector? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var castContext: CastContext? = null
|
||||||
|
private var registered = false
|
||||||
|
|
||||||
|
private val routes = mutableMapOf<String, MediaRouter.RouteInfo>()
|
||||||
|
|
||||||
|
private val routerCallback =
|
||||||
|
object : MediaRouter.Callback() {
|
||||||
|
override fun onRouteAdded(
|
||||||
|
router: MediaRouter,
|
||||||
|
route: MediaRouter.RouteInfo,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "onRouteAdded id=${route.id} name=${route.name}" }
|
||||||
|
updateRoutes(router)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRouteRemoved(
|
||||||
|
router: MediaRouter,
|
||||||
|
route: MediaRouter.RouteInfo,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "onRouteRemoved id=${route.id} name=${route.name}" }
|
||||||
|
updateRoutes(router)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRouteChanged(
|
||||||
|
router: MediaRouter,
|
||||||
|
route: MediaRouter.RouteInfo,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "onRouteChanged id=${route.id} name=${route.name} selected=${route.isSelected}" }
|
||||||
|
updateRoutes(router)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sessionListener =
|
||||||
|
object : SessionManagerListener<CastSession> {
|
||||||
|
override fun onSessionStarting(session: CastSession) {
|
||||||
|
Log.d(TAG) { "session.onStarting" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionStarted(
|
||||||
|
session: CastSession,
|
||||||
|
sessionId: String,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" }
|
||||||
|
pendingSessionStart?.complete(true)
|
||||||
|
pendingSessionStart = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionStartFailed(
|
||||||
|
session: CastSession,
|
||||||
|
error: Int,
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "session.onStartFailed error=$error")
|
||||||
|
pendingSessionStart?.complete(false)
|
||||||
|
pendingSessionStart = null
|
||||||
|
sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionEnding(session: CastSession) {
|
||||||
|
Log.d(TAG) { "session.onEnding" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionEnded(
|
||||||
|
session: CastSession,
|
||||||
|
error: Int,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "session.onEnded error=$error" }
|
||||||
|
// If a cast() was awaiting a session start, this is also a terminal
|
||||||
|
// outcome — the session never reached a usable state. Without
|
||||||
|
// completing here the cast coroutine hangs and the discovery
|
||||||
|
// ref-count leaks +1 for every failed attempt.
|
||||||
|
pendingSessionStart?.complete(false)
|
||||||
|
pendingSessionStart = null
|
||||||
|
sessionFlow.value = CastSessionState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionResuming(
|
||||||
|
session: CastSession,
|
||||||
|
sessionId: String,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "session.onResuming id=$sessionId" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionResumed(
|
||||||
|
session: CastSession,
|
||||||
|
wasSuspended: Boolean,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "session.onResumed wasSuspended=$wasSuspended" }
|
||||||
|
// Resume counts as the session being usable — let cast() proceed.
|
||||||
|
pendingSessionStart?.complete(true)
|
||||||
|
pendingSessionStart = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionResumeFailed(
|
||||||
|
session: CastSession,
|
||||||
|
error: Int,
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "session.onResumeFailed error=$error")
|
||||||
|
pendingSessionStart?.complete(false)
|
||||||
|
pendingSessionStart = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionSuspended(
|
||||||
|
session: CastSession,
|
||||||
|
reason: Int,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "session.onSuspended reason=$reason" }
|
||||||
|
// Suspended sessions can't accept loads — fail any pending start so
|
||||||
|
// the caller doesn't try to call remoteMediaClient.load() against
|
||||||
|
// a broken session.
|
||||||
|
pendingSessionStart?.complete(false)
|
||||||
|
pendingSessionStart = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var pendingSessionStart: CompletableDeferred<Boolean>? = null
|
||||||
|
|
||||||
|
private fun currentDevice(): CastDevice? =
|
||||||
|
when (val s = sessionFlow.value) {
|
||||||
|
is CastSessionState.Connecting -> s.device
|
||||||
|
is CastSessionState.Casting -> s.device
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureCastContext(): CastContext? {
|
||||||
|
castContext?.let { return it }
|
||||||
|
val gms = GoogleApiAvailability.getInstance()
|
||||||
|
val status = gms.isGooglePlayServicesAvailable(appContext)
|
||||||
|
if (status != ConnectionResult.SUCCESS) {
|
||||||
|
Log.d(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
// The blocking overload is the right choice here: we only call it on
|
||||||
|
// the main thread, OptionsProvider is declared in the play manifest,
|
||||||
|
// and the SDK caches the singleton after the first call.
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
CastContext.getSharedInstance(appContext).also { castContext = it }
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "Failed to initialize CastContext: ${t.message}", t)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startDiscovery() {
|
||||||
|
Log.d(TAG) { "startDiscovery (already registered? $registered)" }
|
||||||
|
main.post {
|
||||||
|
if (registered) {
|
||||||
|
Log.d(TAG) { "startDiscovery: already registered, no-op" }
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val ctx = ensureCastContext()
|
||||||
|
if (ctx == null) {
|
||||||
|
Log.d(TAG) { "startDiscovery: CastContext unavailable, aborting" }
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val router = MediaRouter.getInstance(appContext)
|
||||||
|
val selector =
|
||||||
|
MediaRouteSelector
|
||||||
|
.Builder()
|
||||||
|
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||||
|
.build()
|
||||||
|
router.addCallback(selector, routerCallback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
|
||||||
|
ctx.sessionManager.addSessionManagerListener(sessionListener, CastSession::class.java)
|
||||||
|
mediaRouter = router
|
||||||
|
routeSelector = selector
|
||||||
|
registered = true
|
||||||
|
Log.d(TAG) { "startDiscovery: registered router callback + session listener" }
|
||||||
|
updateRoutes(router)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopDiscovery() {
|
||||||
|
Log.d(TAG) { "stopDiscovery (registered=$registered)" }
|
||||||
|
main.post {
|
||||||
|
if (!registered) return@post
|
||||||
|
mediaRouter?.removeCallback(routerCallback)
|
||||||
|
castContext?.sessionManager?.removeSessionManagerListener(sessionListener, CastSession::class.java)
|
||||||
|
registered = false
|
||||||
|
routes.clear()
|
||||||
|
devicesFlow.value = emptyList()
|
||||||
|
Log.d(TAG) { "stopDiscovery: unregistered" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRoutes(router: MediaRouter) {
|
||||||
|
val selector = routeSelector ?: return
|
||||||
|
routes.clear()
|
||||||
|
for (route in router.routes) {
|
||||||
|
if (route.isDefault || route.isBluetooth) continue
|
||||||
|
if (!route.matchesSelector(selector)) continue
|
||||||
|
routes[route.id] = route
|
||||||
|
}
|
||||||
|
val list =
|
||||||
|
routes.values.map { route ->
|
||||||
|
CastDevice(
|
||||||
|
id = route.id,
|
||||||
|
name = route.name,
|
||||||
|
kind = CastDeviceKind.Chromecast,
|
||||||
|
casterId = id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" }
|
||||||
|
devicesFlow.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cast(
|
||||||
|
device: CastDevice,
|
||||||
|
request: CastRequest,
|
||||||
|
) {
|
||||||
|
Log.d(TAG) { "cast device=${device.name} url=${request.url}" }
|
||||||
|
val ctx = withContext(Dispatchers.Main) { ensureCastContext() }
|
||||||
|
if (ctx == null) {
|
||||||
|
Log.w(TAG, "cast: CastContext unavailable")
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "Google Play services unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val route =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
routes[device.id] ?: mediaRouter?.routes?.firstOrNull { it.id == device.id }
|
||||||
|
}
|
||||||
|
if (route == null) {
|
||||||
|
Log.w(TAG, "cast: route ${device.id} not in current set; offline?")
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "Device went offline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionFlow.value = CastSessionState.Connecting(device)
|
||||||
|
|
||||||
|
val started =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val existing = ctx.sessionManager.currentCastSession
|
||||||
|
Log.d(TAG) {
|
||||||
|
"cast: existing session connected=${existing?.isConnected} hasClient=${existing?.remoteMediaClient != null}"
|
||||||
|
}
|
||||||
|
val pending = CompletableDeferred<Boolean>()
|
||||||
|
pendingSessionStart = pending
|
||||||
|
try {
|
||||||
|
Log.d(TAG) { "cast: selectRoute id=${route.id}" }
|
||||||
|
mediaRouter?.selectRoute(route)
|
||||||
|
if (existing?.isConnected == true) {
|
||||||
|
Log.d(TAG) { "cast: reusing already-connected session, completing immediately" }
|
||||||
|
pending.complete(true)
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "cast: selectRoute threw: ${t.message}", t)
|
||||||
|
pending.complete(false)
|
||||||
|
}
|
||||||
|
// Defence in depth: if a callback is somehow missed (SDK bug,
|
||||||
|
// process killed mid-flight, …) don't hang the coroutine forever.
|
||||||
|
// The session listener already covers every documented terminal
|
||||||
|
// state, so reaching the timeout means something genuinely went
|
||||||
|
// wrong on the SDK side.
|
||||||
|
val outcome = withTimeoutOrNull(SESSION_START_TIMEOUT_MS) { pending.await() }
|
||||||
|
if (outcome == null) {
|
||||||
|
Log.w(TAG, "cast: session start timed out after ${SESSION_START_TIMEOUT_MS}ms")
|
||||||
|
pendingSessionStart = null
|
||||||
|
}
|
||||||
|
outcome ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!started) {
|
||||||
|
Log.w(TAG, "cast: session start refused")
|
||||||
|
sessionFlow.value = CastSessionState.Error(device, "Cast session refused")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val ok =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val session = ctx.sessionManager.currentCastSession
|
||||||
|
val client = session?.remoteMediaClient
|
||||||
|
Log.d(TAG) {
|
||||||
|
"cast: post-start session=${session != null} connected=${session?.isConnected} client=${client != null}"
|
||||||
|
}
|
||||||
|
if (client == null) {
|
||||||
|
Log.w(TAG, "cast: remoteMediaClient is null (session not fully ready); load skipped")
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
client.load(buildLoadRequest(request))
|
||||||
|
Log.d(TAG) { "cast: load() submitted (status=${client.playerState})" }
|
||||||
|
true
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "cast: remoteMediaClient.load failed: ${t.message}", t)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionFlow.value =
|
||||||
|
if (ok) {
|
||||||
|
CastSessionState.Casting(device, request)
|
||||||
|
} else {
|
||||||
|
CastSessionState.Error(device, "Could not load media on receiver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLoadRequest(request: CastRequest): MediaLoadRequestData {
|
||||||
|
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE)
|
||||||
|
request.title?.let { metadata.putString(MediaMetadata.KEY_TITLE, it) }
|
||||||
|
request.artworkUri?.let {
|
||||||
|
runCatching { metadata.addImage(WebImage(it.toUri())) }
|
||||||
|
}
|
||||||
|
val info =
|
||||||
|
MediaInfo
|
||||||
|
.Builder(request.url)
|
||||||
|
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||||
|
.setContentType(request.effectiveMimeType())
|
||||||
|
.setMetadata(metadata)
|
||||||
|
.build()
|
||||||
|
return MediaLoadRequestData
|
||||||
|
.Builder()
|
||||||
|
.setMediaInfo(info)
|
||||||
|
.setAutoplay(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stopCasting() {
|
||||||
|
Log.d(TAG) { "stopCasting" }
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
try {
|
||||||
|
castContext?.sessionManager?.endCurrentSession(true)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.w(TAG, "endCurrentSession failed: ${t.message}", t)
|
||||||
|
}
|
||||||
|
mediaRouter?.unselect(MediaRouter.UNSELECT_REASON_STOPPED)
|
||||||
|
}
|
||||||
|
sessionFlow.value = CastSessionState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
+2
@@ -55,6 +55,8 @@ object MaterialSymbols {
|
|||||||
val CameraFront = MaterialSymbol("\uF2C9")
|
val CameraFront = MaterialSymbol("\uF2C9")
|
||||||
val CameraRear = MaterialSymbol("\uF2C8")
|
val CameraRear = MaterialSymbol("\uF2C8")
|
||||||
val Cancel = MaterialSymbol("\uE888")
|
val Cancel = MaterialSymbol("\uE888")
|
||||||
|
val Cast = MaterialSymbol("\uE307")
|
||||||
|
val CastConnected = MaterialSymbol("\uE308")
|
||||||
val CellTower = MaterialSymbol("\uEBBA")
|
val CellTower = MaterialSymbol("\uEBBA")
|
||||||
val Chat = MaterialSymbol("\uE0C9")
|
val Chat = MaterialSymbol("\uE0C9")
|
||||||
val Check = MaterialSymbol("\uE5CA")
|
val Check = MaterialSymbol("\uE5CA")
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ tarsosdsp = "2.5"
|
|||||||
translate = "17.0.3"
|
translate = "17.0.3"
|
||||||
jetbrainsCompose = "1.10.3"
|
jetbrainsCompose = "1.10.3"
|
||||||
unifiedpush = "3.0.10"
|
unifiedpush = "3.0.10"
|
||||||
|
jupnp = "3.0.4"
|
||||||
|
# jUPnP 3.0.x's AndroidUpnpServiceConfiguration.createStreamServer wires in
|
||||||
|
# JettyServletContainer at runtime — without these jUPnP throws
|
||||||
|
# NoClassDefFoundError on startup() and DLNA discovery never runs. Stay on the
|
||||||
|
# 9.4 line because jUPnP 3.0.x targets javax.servlet (Jetty 10+ uses jakarta).
|
||||||
|
jetty = "9.4.57.v20241219"
|
||||||
|
playServicesCast = "22.1.0"
|
||||||
vico-charts-compose = "3.1.0"
|
vico-charts-compose = "3.1.0"
|
||||||
zelory = "3.0.1"
|
zelory = "3.0.1"
|
||||||
zoomable = "2.11.1"
|
zoomable = "2.11.1"
|
||||||
@@ -188,6 +195,13 @@ schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr25
|
|||||||
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
||||||
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
|
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
|
||||||
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
||||||
|
jupnp-android = { group = "org.jupnp", name = "org.jupnp.android", version.ref = "jupnp" }
|
||||||
|
jupnp-core = { group = "org.jupnp", name = "org.jupnp", version.ref = "jupnp" }
|
||||||
|
jupnp-support = { group = "org.jupnp", name = "org.jupnp.support", version.ref = "jupnp" }
|
||||||
|
jetty-server = { group = "org.eclipse.jetty", name = "jetty-server", version.ref = "jetty" }
|
||||||
|
jetty-servlet = { group = "org.eclipse.jetty", name = "jetty-servlet", version.ref = "jetty" }
|
||||||
|
jetty-client = { group = "org.eclipse.jetty", name = "jetty-client", version.ref = "jetty" }
|
||||||
|
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" }
|
||||||
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
|
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
|
||||||
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
|
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
|
||||||
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
|
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
|
||||||
|
|||||||
Reference in New Issue
Block a user