Rip DLNA code paths, collapse to Chromecast-only
- chore(cast): drop protocol-toggle settings, strings, perms, deps - gate cast on play flavor via BuildConfig.IS_CASTING_AVAILABLE - fix(cast): keep local player paused across recompose; disable transport while casting
This commit is contained in:
+2
-14
@@ -199,10 +199,12 @@ android {
|
|||||||
play {
|
play {
|
||||||
getIsDefault().set(true)
|
getIsDefault().set(true)
|
||||||
dimension "channel"
|
dimension "channel"
|
||||||
|
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
fdroid {
|
fdroid {
|
||||||
dimension "channel"
|
dimension "channel"
|
||||||
|
buildConfigField "boolean", "IS_CASTING_AVAILABLE", "false"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,20 +381,6 @@ 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
|
// Google Cast SDK — Chromecast support. Play flavor only because the
|
||||||
// framework hard-depends on Google Play services, which is unavailable
|
// framework hard-depends on Google Play services, which is unavailable
|
||||||
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
|
// on de-Googled / GrapheneOS devices that ship the F-Droid build.
|
||||||
|
|||||||
Vendored
-7
@@ -70,10 +70,3 @@
|
|||||||
<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.**
|
|
||||||
|
|||||||
+11
-12
@@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.service.cast.chromecast
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastDevice
|
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.CastRequest
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
import com.vitorpamplona.amethyst.service.cast.VideoCaster
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -33,26 +31,27 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
/**
|
/**
|
||||||
* F-Droid stub: the Google Cast SDK requires Google Play services, which are
|
* 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
|
* not present on the FOSS build. This caster always reports an empty device
|
||||||
* list so the registry simply skips Chromecast paths at runtime.
|
* list so the registry simply returns no devices. The cast button is hidden
|
||||||
|
* on fdroid via `BuildConfig.IS_CASTING_AVAILABLE`, so this stub should never
|
||||||
|
* be exercised at runtime; it exists to keep the shared-source-set
|
||||||
|
* `CastRegistry` constructible.
|
||||||
*/
|
*/
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
class ChromecastCaster(
|
class ChromecastCaster(
|
||||||
appContext: Context,
|
appContext: Context,
|
||||||
) : VideoCaster {
|
) {
|
||||||
override val kind: CastDeviceKind = CastDeviceKind.Chromecast
|
val devices: StateFlow<List<CastDevice>> = MutableStateFlow<List<CastDevice>>(emptyList()).asStateFlow()
|
||||||
|
|
||||||
override val devices: StateFlow<List<CastDevice>> = MutableStateFlow<List<CastDevice>>(emptyList()).asStateFlow()
|
val sessionState: StateFlow<CastSessionState> = MutableStateFlow<CastSessionState>(CastSessionState.Idle).asStateFlow()
|
||||||
|
|
||||||
override val sessionState: StateFlow<CastSessionState> = MutableStateFlow<CastSessionState>(CastSessionState.Idle).asStateFlow()
|
fun startDiscovery() = Unit
|
||||||
|
|
||||||
override fun startDiscovery() = Unit
|
fun stopDiscovery() = Unit
|
||||||
|
|
||||||
override fun stopDiscovery() = Unit
|
suspend fun cast(
|
||||||
|
|
||||||
override suspend fun cast(
|
|
||||||
device: CastDevice,
|
device: CastDevice,
|
||||||
request: CastRequest,
|
request: CastRequest,
|
||||||
) = Unit
|
) = Unit
|
||||||
|
|
||||||
override suspend fun stopCasting() = Unit
|
suspend fun stopCasting() = Unit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,6 @@
|
|||||||
(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" />
|
||||||
|
|||||||
@@ -493,12 +493,12 @@ class AppModules(
|
|||||||
Nip95CacheFactory.new(appContext)
|
Nip95CacheFactory.new(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LAN cast registry — aggregates Chromecast (play flavor only) and DLNA
|
// LAN cast registry — Chromecast only (play flavor real, fdroid no-op
|
||||||
// discovery into a single device list. Discovery starts only when the
|
// stub). Discovery starts only when the picker dialog opens; idle by
|
||||||
// picker dialog opens; idle by default to keep multicast traffic off.
|
// default to keep multicast traffic off.
|
||||||
val castRegistry: CastRegistry by lazy {
|
val castRegistry: CastRegistry by lazy {
|
||||||
Log.d("AppModules", "CastRegistry Init")
|
Log.d("AppModules", "CastRegistry Init")
|
||||||
CastRegistry(appContext, applicationIOScope, uiPrefs.value.castProtocol)
|
CastRegistry(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// local video cache with disk + memory
|
// local video cache with disk + memory
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ 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(
|
||||||
@@ -148,23 +147,6 @@ 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,7 +47,6 @@ 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?>>(
|
||||||
@@ -69,7 +68,6 @@ 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.
|
||||||
@@ -95,7 +93,6 @@ 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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +116,6 @@ 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 {
|
||||||
@@ -197,10 +193,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -238,7 +230,6 @@ 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,7 +32,6 @@ 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
|
||||||
@@ -109,7 +108,6 @@ 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 {
|
||||||
@@ -136,7 +134,6 @@ 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 { runCatching { CastProtocolType.valueOf(it) }.getOrNull() } ?: CastProtocolType.BOTH,
|
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
@@ -176,7 +173,6 @@ 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
|
||||||
|
|||||||
@@ -22,16 +22,10 @@ package com.vitorpamplona.amethyst.service.cast
|
|||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
enum class CastDeviceKind {
|
|
||||||
Chromecast,
|
|
||||||
Dlna,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class CastDevice(
|
data class CastDevice(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val kind: CastDeviceKind,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|||||||
@@ -21,138 +21,36 @@
|
|||||||
package com.vitorpamplona.amethyst.service.cast
|
package com.vitorpamplona.amethyst.service.cast
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.vitorpamplona.amethyst.model.CastProtocolType
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster
|
import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster
|
||||||
import com.vitorpamplona.amethyst.service.cast.dlna.DlnaCaster
|
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.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
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
private const val TAG = "CastRegistry"
|
private const val TAG = "CastRegistry"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregates every [VideoCaster] implementation available on this build into
|
* Thin wrapper around the platform-flavor [ChromecastCaster]. Ref-counts
|
||||||
* a single device list and a single active-session view, so the cast UI
|
* discovery starts/stops so multiple UI callers (picker dialog open + cast
|
||||||
* doesn't care whether a device speaks Chromecast or DLNA.
|
* tap) can coordinate without tearing the underlying scan down mid-cast.
|
||||||
*
|
*
|
||||||
* Discovery is started/stopped explicitly by callers (typically the picker
|
* On fdroid the wrapped [ChromecastCaster] is a no-op stub — the registry
|
||||||
* dialog's DisposableEffect) so that SSDP multicast traffic + the
|
* still constructs and answers cleanly, but discovery returns no devices.
|
||||||
* MulticastLock are only paid for while the user is choosing.
|
* Casting is hidden entirely on fdroid via `BuildConfig.IS_CASTING_AVAILABLE`.
|
||||||
*
|
|
||||||
* 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(
|
class CastRegistry(
|
||||||
appContext: Context,
|
appContext: Context,
|
||||||
scope: CoroutineScope,
|
|
||||||
private val protocolFlow: StateFlow<CastProtocolType> =
|
|
||||||
MutableStateFlow(CastProtocolType.BOTH),
|
|
||||||
) {
|
) {
|
||||||
private val casters: List<VideoCaster> =
|
private val caster = ChromecastCaster(appContext)
|
||||||
listOf(
|
|
||||||
ChromecastCaster(appContext),
|
|
||||||
DlnaCaster(appContext),
|
|
||||||
)
|
|
||||||
|
|
||||||
val devices: StateFlow<List<CastDevice>> =
|
val devices: StateFlow<List<CastDevice>> = caster.devices
|
||||||
combine(
|
val sessionState: StateFlow<CastSessionState> = caster.sessionState
|
||||||
combine(casters.map { it.devices }) { snapshots -> snapshots.toList() },
|
|
||||||
protocolFlow,
|
|
||||||
) { snapshots, proto ->
|
|
||||||
val merged =
|
|
||||||
snapshots.flatMapIndexed { index, list ->
|
|
||||||
if (isCasterEnabled(casters[index], proto)) list else emptyList()
|
|
||||||
}
|
|
||||||
Log.d(TAG) {
|
|
||||||
"devices flow update proto=$proto " +
|
|
||||||
snapshots.mapIndexed { i, l -> "${casters[i].kind}=${l.size}" }.joinToString() +
|
|
||||||
" merged=${merged.size} -> [${merged.joinToString { "${it.kind}:${it.name}" }}]"
|
|
||||||
}
|
|
||||||
merged
|
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
|
||||||
|
|
||||||
val sessionState: StateFlow<CastSessionState> =
|
|
||||||
combine(casters.map { it.sessionState }) { states ->
|
|
||||||
states.firstOrNull { it !is CastSessionState.Idle } ?: CastSessionState.Idle
|
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), CastSessionState.Idle)
|
|
||||||
|
|
||||||
private val refCount = AtomicInteger(0)
|
private val refCount = AtomicInteger(0)
|
||||||
private val active =
|
|
||||||
ConcurrentHashMap<CastDeviceKind, Boolean>().apply {
|
|
||||||
casters.forEach { put(it.kind, false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
Log.d(TAG) {
|
|
||||||
"init casters=[${casters.joinToString { it.kind.name }}] initialProtocol=${protocolFlow.value}"
|
|
||||||
}
|
|
||||||
scope.launch {
|
|
||||||
protocolFlow.collect { proto ->
|
|
||||||
Log.d(TAG) { "protocolFlow change -> $proto refCount=${refCount.get()}" }
|
|
||||||
if (refCount.get() > 0) reconcile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isCasterEnabled(
|
|
||||||
caster: VideoCaster,
|
|
||||||
proto: CastProtocolType,
|
|
||||||
): Boolean =
|
|
||||||
when (proto) {
|
|
||||||
CastProtocolType.BOTH -> true
|
|
||||||
CastProtocolType.CHROMECAST -> caster.kind == CastDeviceKind.Chromecast
|
|
||||||
CastProtocolType.DLNA -> caster.kind == CastDeviceKind.Dlna
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun reconcile() {
|
|
||||||
val proto = protocolFlow.value
|
|
||||||
casters.forEach { caster ->
|
|
||||||
val shouldRun = isCasterEnabled(caster, proto)
|
|
||||||
val running = active[caster.kind] == true
|
|
||||||
if (shouldRun && !running) {
|
|
||||||
Log.d(TAG) { "reconcile START ${caster.kind} (proto=$proto)" }
|
|
||||||
runCatching { caster.startDiscovery() }
|
|
||||||
.onFailure { Log.w(TAG, "reconcile START ${caster.kind} threw: ${it.message}", it) }
|
|
||||||
active[caster.kind] = true
|
|
||||||
} else if (!shouldRun && running) {
|
|
||||||
Log.d(TAG) { "reconcile STOP ${caster.kind} (proto=$proto)" }
|
|
||||||
runCatching { caster.stopDiscovery() }
|
|
||||||
.onFailure { Log.w(TAG, "reconcile STOP ${caster.kind} threw: ${it.message}", it) }
|
|
||||||
active[caster.kind] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun stopAll() {
|
|
||||||
Log.d(TAG) { "stopAll active=${active.filterValues { it }.keys}" }
|
|
||||||
casters.forEach { caster ->
|
|
||||||
if (active[caster.kind] == true) {
|
|
||||||
runCatching { caster.stopDiscovery() }
|
|
||||||
.onFailure { Log.w(TAG, "stopAll ${caster.kind} threw: ${it.message}", it) }
|
|
||||||
active[caster.kind] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startDiscovery() {
|
fun startDiscovery() {
|
||||||
val before = refCount.getAndIncrement()
|
val before = refCount.getAndIncrement()
|
||||||
Log.d(TAG) { "startDiscovery refCount $before -> ${before + 1}" }
|
Log.d(TAG) { "startDiscovery refCount $before -> ${before + 1}" }
|
||||||
if (before == 0) reconcile()
|
if (before == 0) caster.startDiscovery()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopDiscovery() {
|
fun stopDiscovery() {
|
||||||
@@ -160,7 +58,7 @@ class CastRegistry(
|
|||||||
Log.d(TAG) { "stopDiscovery refCount -> $after" }
|
Log.d(TAG) { "stopDiscovery refCount -> $after" }
|
||||||
if (after <= 0) {
|
if (after <= 0) {
|
||||||
refCount.set(0)
|
refCount.set(0)
|
||||||
stopAll()
|
caster.stopDiscovery()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,32 +66,12 @@ class CastRegistry(
|
|||||||
device: CastDevice,
|
device: CastDevice,
|
||||||
request: CastRequest,
|
request: CastRequest,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG) {
|
Log.d(TAG) { "cast device=${device.name} url=${request.url} mime=${request.mimeType}" }
|
||||||
"cast device=${device.kind}:${device.name} url=${request.url} mime=${request.mimeType}"
|
|
||||||
}
|
|
||||||
val caster = casters.firstOrNull { it.kind == device.kind }
|
|
||||||
if (caster == null) {
|
|
||||||
Log.w(TAG, "cast: no caster found for kind=${device.kind}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
caster.cast(device, request)
|
caster.cast(device, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun stopCasting() {
|
suspend fun stopCasting() {
|
||||||
Log.d(TAG) { "stopCasting (broadcast to all casters)" }
|
Log.d(TAG) { "stopCasting" }
|
||||||
coroutineScope {
|
caster.stopCasting()
|
||||||
casters
|
|
||||||
.map { caster ->
|
|
||||||
async {
|
|
||||||
try {
|
|
||||||
caster.stopCasting()
|
|
||||||
} catch (ce: CancellationException) {
|
|
||||||
throw ce
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
Log.w(TAG, "stopCasting ${caster.kind} threw: ${t.message}", t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Vitor Pamplona
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
* this software and associated documentation files (the "Software"), to deal in
|
|
||||||
* the Software without restriction, including without limitation the rights to use,
|
|
||||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
||||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
* subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in all
|
|
||||||
* copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
|
||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
package com.vitorpamplona.amethyst.service.cast
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common contract for any LAN cast protocol that Amethyst can drive — currently
|
|
||||||
* Chromecast (Google Cast SDK, play flavor only) and DLNA / UPnP AVTransport
|
|
||||||
* (jUPnP, both flavors). The registry layer aggregates one or more of these
|
|
||||||
* to give the UI a unified device list and active-session state.
|
|
||||||
*/
|
|
||||||
interface VideoCaster {
|
|
||||||
/** Which protocol this caster speaks. The registry routes by [CastDevice.kind]. */
|
|
||||||
val kind: CastDeviceKind
|
|
||||||
|
|
||||||
/** Discovered receiver devices; emits a snapshot whenever the set changes. */
|
|
||||||
val devices: StateFlow<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].
|
|
||||||
*/
|
|
||||||
suspend fun cast(
|
|
||||||
device: CastDevice,
|
|
||||||
request: CastRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** End the active session if any. Idempotent. */
|
|
||||||
suspend fun stopCasting()
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Vitor Pamplona
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
* this software and associated documentation files (the "Software"), to deal in
|
|
||||||
* the Software without restriction, including without limitation the rights to use,
|
|
||||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
|
||||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
* subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in all
|
|
||||||
* copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
|
||||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
package com.vitorpamplona.amethyst.service.cast.dlna
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastDevice
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastDeviceKind
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.VideoCaster
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.effectiveMimeType
|
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import org.jupnp.UpnpService
|
|
||||||
import org.jupnp.UpnpServiceImpl
|
|
||||||
import org.jupnp.android.AndroidUpnpServiceConfiguration
|
|
||||||
import org.jupnp.model.action.ActionInvocation
|
|
||||||
import org.jupnp.model.message.UpnpResponse
|
|
||||||
import org.jupnp.model.message.header.STAllHeader
|
|
||||||
import org.jupnp.model.meta.LocalDevice
|
|
||||||
import org.jupnp.model.meta.RemoteDevice
|
|
||||||
import org.jupnp.model.types.UDADeviceType
|
|
||||||
import org.jupnp.model.types.UDAServiceType
|
|
||||||
import org.jupnp.model.types.UDN
|
|
||||||
import org.jupnp.registry.Registry
|
|
||||||
import org.jupnp.registry.RegistryListener
|
|
||||||
import org.jupnp.support.avtransport.callback.Play
|
|
||||||
import org.jupnp.support.avtransport.callback.SetAVTransportURI
|
|
||||||
import org.jupnp.support.avtransport.callback.Stop
|
|
||||||
import org.jupnp.support.contentdirectory.DIDLParser
|
|
||||||
import org.jupnp.support.model.DIDLContent
|
|
||||||
import org.jupnp.support.model.ProtocolInfo
|
|
||||||
import org.jupnp.support.model.Res
|
|
||||||
import org.jupnp.support.model.item.VideoItem
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
private const val TAG = "DlnaCaster"
|
|
||||||
|
|
||||||
private val MEDIA_RENDERER = UDADeviceType("MediaRenderer")
|
|
||||||
private val AV_TRANSPORT = UDAServiceType("AVTransport")
|
|
||||||
|
|
||||||
class DlnaCaster(
|
|
||||||
private val appContext: Context,
|
|
||||||
) : VideoCaster {
|
|
||||||
override val kind: CastDeviceKind = CastDeviceKind.Dlna
|
|
||||||
|
|
||||||
private val devicesFlow = MutableStateFlow<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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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)
|
|
||||||
""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
@@ -154,6 +154,7 @@ fun RenderVideoPlayer(
|
|||||||
RenderCenterButtons(
|
RenderCenterButtons(
|
||||||
controllerState = controllerState,
|
controllerState = controllerState,
|
||||||
controllerVisible = controllerVisible,
|
controllerVisible = controllerVisible,
|
||||||
|
videoUri = mediaItem.src.videoUri,
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
isLiveStream = isLive,
|
isLiveStream = isLive,
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-1
@@ -81,6 +81,7 @@ fun AnimatedPlayPauseButton(
|
|||||||
controllerVisible: State<Boolean>,
|
controllerVisible: State<Boolean>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -89,13 +90,14 @@ fun AnimatedPlayPauseButton(
|
|||||||
enter = remember { fadeIn() },
|
enter = remember { fadeIn() },
|
||||||
exit = remember { fadeOut() },
|
exit = remember { fadeOut() },
|
||||||
) {
|
) {
|
||||||
PlayPauseButton(isPlaying, onClick)
|
PlayPauseButton(isPlaying, enabled, onClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayPauseButton(
|
fun PlayPauseButton(
|
||||||
isPlaying: Boolean,
|
isPlaying: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) {
|
Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) {
|
||||||
@@ -108,6 +110,7 @@ fun PlayPauseButton(
|
|||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
modifier = Modifier.size(80.dp),
|
modifier = Modifier.size(80.dp),
|
||||||
) {
|
) {
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
|
|||||||
+30
-4
@@ -26,11 +26,15 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
import androidx.media3.ui.compose.state.rememberPlayPauseButtonState
|
||||||
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.seekBackward
|
import com.vitorpamplona.amethyst.service.playback.composable.seekBackward
|
||||||
import com.vitorpamplona.amethyst.service.playback.composable.skipForward
|
import com.vitorpamplona.amethyst.service.playback.composable.skipForward
|
||||||
@@ -41,34 +45,56 @@ import kotlinx.coroutines.delay
|
|||||||
fun RenderCenterButtons(
|
fun RenderCenterButtons(
|
||||||
controllerState: MediaControllerState,
|
controllerState: MediaControllerState,
|
||||||
controllerVisible: MutableState<Boolean>,
|
controllerVisible: MutableState<Boolean>,
|
||||||
|
videoUri: String,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
isLiveStream: Boolean = false,
|
isLiveStream: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val state = rememberPlayPauseButtonState(controllerState.controller)
|
val state = rememberPlayPauseButtonState(controllerState.controller)
|
||||||
|
|
||||||
|
val castSessionState by Amethyst.instance.castRegistry.sessionState
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val isCastingThisVideo =
|
||||||
|
(castSessionState as? CastSessionState.Casting)?.request?.url == videoUri
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (!isLiveStream) {
|
if (!isLiveStream) {
|
||||||
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = false) {
|
AnimatedSkipButton(
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
isForward = false,
|
||||||
|
enabled = !isCastingThisVideo,
|
||||||
|
) {
|
||||||
controllerState.controller.seekBackward()
|
controllerState.controller.seekBackward()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) {
|
AnimatedPlayPauseButton(
|
||||||
|
controllerVisible,
|
||||||
|
Modifier,
|
||||||
|
!state.showPlay,
|
||||||
|
enabled = !isCastingThisVideo,
|
||||||
|
) {
|
||||||
state.onClick()
|
state.onClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLiveStream) {
|
if (!isLiveStream) {
|
||||||
AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) {
|
AnimatedSkipButton(
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
isForward = true,
|
||||||
|
enabled = !isCastingThisVideo,
|
||||||
|
) {
|
||||||
controllerState.controller.skipForward()
|
controllerState.controller.skipForward()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.showPlay) {
|
// Auto-hide controls 3s after playback begins — but stay visible while
|
||||||
|
// casting so the user can see the disabled transport controls and find
|
||||||
|
// the cast-stop button in the top bar without tapping the screen first.
|
||||||
|
if (!state.showPlay && !isCastingThisVideo) {
|
||||||
LaunchedEffect(state.showPlay) {
|
LaunchedEffect(state.showPlay) {
|
||||||
delay(3000)
|
delay(3000)
|
||||||
controllerVisible.value = false
|
controllerVisible.value = false
|
||||||
|
|||||||
+56
-15
@@ -50,6 +50,7 @@ import androidx.media3.common.Player
|
|||||||
import androidx.media3.common.Tracks
|
import androidx.media3.common.Tracks
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
|
import com.vitorpamplona.amethyst.BuildConfig
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||||
@@ -143,19 +144,39 @@ fun RenderTopButtons(
|
|||||||
val overflowQualityOpen = remember { mutableStateOf(false) }
|
val overflowQualityOpen = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pause local playback while this video is casting so audio doesn't
|
// Pause local playback while this video is casting so audio doesn't
|
||||||
// double up; only resume on the transition we caused.
|
// double up. Has to survive (a) the player instance being recreated when
|
||||||
|
// the composable scrolls off-screen and back on, and (b) any later
|
||||||
|
// playWhenReady=true flips from auto-play-on-attach or end-of-loading.
|
||||||
|
// A one-shot LaunchedEffect would only pause once; a Player.Listener
|
||||||
|
// re-pauses every time the player flips back to playing.
|
||||||
val castSessionStateForLocal by Amethyst.instance.castRegistry.sessionState
|
val castSessionStateForLocal by Amethyst.instance.castRegistry.sessionState
|
||||||
.collectAsStateWithLifecycle()
|
.collectAsStateWithLifecycle()
|
||||||
val wasCastingThisVideo = remember { mutableStateOf(false) }
|
val isCastingThisVideo =
|
||||||
LaunchedEffect(castSessionStateForLocal, mediaData.videoUri) {
|
(castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri
|
||||||
val isCastingThis =
|
DisposableEffect(player, isCastingThisVideo) {
|
||||||
(castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri
|
if (isCastingThisVideo) {
|
||||||
if (isCastingThis && !wasCastingThisVideo.value) {
|
|
||||||
player.pause()
|
player.pause()
|
||||||
} else if (!isCastingThis && wasCastingThisVideo.value) {
|
val listener =
|
||||||
|
object : Player.Listener {
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
if (isPlaying) player.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.addListener(listener)
|
||||||
|
onDispose { player.removeListener(listener) }
|
||||||
|
} else {
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-resume local playback only on a genuine cast→no-cast transition
|
||||||
|
// (not on cold-mount when nothing is casting). `previousCasting` is keyed
|
||||||
|
// on videoUri so it resets when the player switches to a different note.
|
||||||
|
val previousCasting = remember(mediaData.videoUri) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(isCastingThisVideo, mediaData.videoUri) {
|
||||||
|
if (previousCasting.value && !isCastingThisVideo) {
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
wasCastingThisVideo.value = isCastingThis
|
previousCasting.value = isCastingThisVideo
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderTopButtons(
|
RenderTopButtons(
|
||||||
@@ -247,13 +268,33 @@ fun RenderTopButtons(
|
|||||||
|
|
||||||
fun isAvailable(action: VideoPlayerAction): Boolean =
|
fun isAvailable(action: VideoPlayerAction): Boolean =
|
||||||
when (action) {
|
when (action) {
|
||||||
VideoPlayerAction.Fullscreen -> onZoomClick != null
|
VideoPlayerAction.Fullscreen -> {
|
||||||
VideoPlayerAction.Mute -> true
|
onZoomClick != null
|
||||||
VideoPlayerAction.Quality -> hasMultipleQualities
|
}
|
||||||
VideoPlayerAction.Share -> true
|
|
||||||
VideoPlayerAction.Download -> !isLive
|
VideoPlayerAction.Mute -> {
|
||||||
VideoPlayerAction.PictureInPicture -> pipSupported
|
true
|
||||||
VideoPlayerAction.Cast -> mediaData.videoUri.startsWith("http", ignoreCase = true)
|
}
|
||||||
|
|
||||||
|
VideoPlayerAction.Quality -> {
|
||||||
|
hasMultipleQualities
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlayerAction.Share -> {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlayerAction.Download -> {
|
||||||
|
!isLive
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlayerAction.PictureInPicture -> {
|
||||||
|
pipSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoPlayerAction.Cast -> {
|
||||||
|
BuildConfig.IS_CASTING_AVAILABLE && mediaData.videoUri.startsWith("http", ignoreCase = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val canFullscreen = onZoomClick != null
|
val canFullscreen = onZoomClick != null
|
||||||
|
|||||||
+6
-3
@@ -69,6 +69,7 @@ fun AnimatedSkipButton(
|
|||||||
controllerVisible: State<Boolean>,
|
controllerVisible: State<Boolean>,
|
||||||
isForward: Boolean,
|
isForward: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -77,18 +78,20 @@ fun AnimatedSkipButton(
|
|||||||
enter = FadeIn,
|
enter = FadeIn,
|
||||||
exit = FadeOut,
|
exit = FadeOut,
|
||||||
) {
|
) {
|
||||||
SkipButton(isForward = isForward, onClick = onClick)
|
SkipButton(isForward = isForward, enabled = enabled, onClick = onClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SkipButton(
|
fun SkipButton(
|
||||||
isForward: Boolean,
|
isForward: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val icon = if (isForward) MaterialSymbols.Forward10 else MaterialSymbols.Replay10
|
val icon = if (isForward) MaterialSymbols.Forward10 else MaterialSymbols.Replay10
|
||||||
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
|
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
|
||||||
IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) {
|
val tint = if (enabled) Color.White else Color.White.copy(alpha = 0.38f)
|
||||||
Icon(symbol = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(32.dp))
|
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
|
||||||
|
Icon(symbol = icon, contentDescription = label, tint = tint, modifier = Modifier.size(32.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
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.CastRegistry
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
import com.vitorpamplona.amethyst.service.cast.CastRequest
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
@@ -81,16 +80,11 @@ fun CastDevicePickerDialog(
|
|||||||
} else {
|
} else {
|
||||||
M3ActionSection {
|
M3ActionSection {
|
||||||
devices.forEach { device ->
|
devices.forEach { device ->
|
||||||
val icon =
|
|
||||||
when (device.kind) {
|
|
||||||
CastDeviceKind.Chromecast -> MaterialSymbols.Cast
|
|
||||||
CastDeviceKind.Dlna -> MaterialSymbols.CastConnected
|
|
||||||
}
|
|
||||||
M3ActionRow(
|
M3ActionRow(
|
||||||
icon = icon,
|
icon = MaterialSymbols.Cast,
|
||||||
text = device.name,
|
text = device.name,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG) { "tap device=${device.kind}:${device.name}" }
|
Log.d(TAG) { "tap device=${device.name}" }
|
||||||
// Keep discovery alive across the dialog's onDispose so the caster's
|
// Keep discovery alive across the dialog's onDispose so the caster's
|
||||||
// session listener stays registered until the cast attempt finishes.
|
// session listener stays registered until the cast attempt finishes.
|
||||||
// Without this the dialog's stopDiscovery (~10ms after tap) tears the
|
// Without this the dialog's stopDiscovery (~10ms after tap) tears the
|
||||||
|
|||||||
-24
@@ -56,14 +56,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.vitorpamplona.amethyst.BuildConfig
|
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
|
||||||
@@ -132,7 +130,6 @@ fun SettingsScreen(
|
|||||||
ShowVideoPlaybackChoice(sharedPrefs)
|
ShowVideoPlaybackChoice(sharedPrefs)
|
||||||
AutoplayVideosChoice(sharedPrefs)
|
AutoplayVideosChoice(sharedPrefs)
|
||||||
if (BuildConfig.FLAVOR == "play") {
|
if (BuildConfig.FLAVOR == "play") {
|
||||||
CastProtocolChoice(sharedPrefs)
|
|
||||||
}
|
}
|
||||||
ShowUrlPreviewChoice(sharedPrefs)
|
ShowUrlPreviewChoice(sharedPrefs)
|
||||||
ShowProfilePictureChoice(sharedPrefs)
|
ShowProfilePictureChoice(sharedPrefs)
|
||||||
@@ -286,27 +283,6 @@ fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CastProtocolChoice(sharedPrefs: UiSettingsFlow) {
|
|
||||||
val castProtocolOptions =
|
|
||||||
persistentListOf(
|
|
||||||
TitleExplainer(stringRes(CastProtocolType.BOTH.resourceId)),
|
|
||||||
TitleExplainer(stringRes(CastProtocolType.CHROMECAST.resourceId)),
|
|
||||||
TitleExplainer(stringRes(CastProtocolType.DLNA.resourceId)),
|
|
||||||
)
|
|
||||||
|
|
||||||
val castProtocolIndex by sharedPrefs.castProtocol.collectAsState()
|
|
||||||
|
|
||||||
SettingsRow(
|
|
||||||
R.string.cast_protocol_setting_title,
|
|
||||||
R.string.cast_protocol_setting_description,
|
|
||||||
castProtocolOptions,
|
|
||||||
castProtocolIndex.screenCode,
|
|
||||||
) {
|
|
||||||
sharedPrefs.castProtocol.tryEmit(parseCastProtocolType(it))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) {
|
fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) {
|
||||||
val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState()
|
val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState()
|
||||||
|
|||||||
+8
-1
@@ -58,6 +58,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
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.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
@@ -103,7 +104,13 @@ fun VideoPlayerSettingsScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
fun VideoPlayerSettingsContent(accountViewModel: AccountViewModel) {
|
fun VideoPlayerSettingsContent(accountViewModel: AccountViewModel) {
|
||||||
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
|
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
|
||||||
var items by remember(buttonItems) { mutableStateOf(buttonItems.toList()) }
|
val displayedItems =
|
||||||
|
if (BuildConfig.IS_CASTING_AVAILABLE) {
|
||||||
|
buttonItems
|
||||||
|
} else {
|
||||||
|
buttonItems.filter { it.action != VideoPlayerAction.Cast }
|
||||||
|
}
|
||||||
|
var items by remember(displayedItems) { mutableStateOf(displayedItems.toList()) }
|
||||||
|
|
||||||
fun save(newItems: List<VideoPlayerButtonItem>) {
|
fun save(newItems: List<VideoPlayerButtonItem>) {
|
||||||
items = newItems.toMutableList()
|
items = newItems.toMutableList()
|
||||||
|
|||||||
@@ -2557,16 +2557,11 @@
|
|||||||
<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 -->
|
<!-- LAN cast (Chromecast) feature -->
|
||||||
<string name="cast_to_device">Cast to device</string>
|
<string name="cast_to_device">Cast to device</string>
|
||||||
<string name="cast_stop_casting">Stop casting</string>
|
<string name="cast_stop_casting">Stop casting</string>
|
||||||
<string name="cast_to_device_dialog_title">Cast to…</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_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>
|
||||||
|
|||||||
+8
-13
@@ -39,10 +39,8 @@ import com.google.android.gms.common.ConnectionResult
|
|||||||
import com.google.android.gms.common.GoogleApiAvailability
|
import com.google.android.gms.common.GoogleApiAvailability
|
||||||
import com.google.android.gms.common.images.WebImage
|
import com.google.android.gms.common.images.WebImage
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastDevice
|
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.CastRequest
|
||||||
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
import com.vitorpamplona.amethyst.service.cast.CastSessionState
|
||||||
import com.vitorpamplona.amethyst.service.cast.VideoCaster
|
|
||||||
import com.vitorpamplona.amethyst.service.cast.effectiveMimeType
|
import com.vitorpamplona.amethyst.service.cast.effectiveMimeType
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -58,7 +56,7 @@ private const val SESSION_START_TIMEOUT_MS = 30_000L
|
|||||||
private const val STOP_AWAIT_TIMEOUT_MS = 5_000L
|
private const val STOP_AWAIT_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Cast (Chromecast) implementation of [VideoCaster].
|
* Google Cast (Chromecast) caster.
|
||||||
*
|
*
|
||||||
* Only present in the play flavor — the F-Droid flavor ships a no-op stub
|
* 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
|
* with the same FQN. The class still works at runtime when Google Play
|
||||||
@@ -67,14 +65,12 @@ private const val STOP_AWAIT_TIMEOUT_MS = 5_000L
|
|||||||
*/
|
*/
|
||||||
class ChromecastCaster(
|
class ChromecastCaster(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
) : VideoCaster {
|
) {
|
||||||
override val kind: CastDeviceKind = CastDeviceKind.Chromecast
|
|
||||||
|
|
||||||
private val devicesFlow = MutableStateFlow<List<CastDevice>>(emptyList())
|
private val devicesFlow = MutableStateFlow<List<CastDevice>>(emptyList())
|
||||||
override val devices: StateFlow<List<CastDevice>> = devicesFlow.asStateFlow()
|
val devices: StateFlow<List<CastDevice>> = devicesFlow.asStateFlow()
|
||||||
|
|
||||||
private val sessionFlow = MutableStateFlow<CastSessionState>(CastSessionState.Idle)
|
private val sessionFlow = MutableStateFlow<CastSessionState>(CastSessionState.Idle)
|
||||||
override val sessionState: StateFlow<CastSessionState> = sessionFlow.asStateFlow()
|
val sessionState: StateFlow<CastSessionState> = sessionFlow.asStateFlow()
|
||||||
|
|
||||||
private val main = Handler(Looper.getMainLooper())
|
private val main = Handler(Looper.getMainLooper())
|
||||||
private var mediaRouter: MediaRouter? = null
|
private var mediaRouter: MediaRouter? = null
|
||||||
@@ -304,7 +300,7 @@ class ChromecastCaster(
|
|||||||
Log.d(TAG) { "sessionListener attached (caster lifetime)" }
|
Log.d(TAG) { "sessionListener attached (caster lifetime)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startDiscovery() {
|
fun startDiscovery() {
|
||||||
Log.d(TAG) { "startDiscovery (already registered? $registered)" }
|
Log.d(TAG) { "startDiscovery (already registered? $registered)" }
|
||||||
main.post {
|
main.post {
|
||||||
if (registered) {
|
if (registered) {
|
||||||
@@ -331,7 +327,7 @@ class ChromecastCaster(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stopDiscovery() {
|
fun stopDiscovery() {
|
||||||
Log.d(TAG) { "stopDiscovery (registered=$registered)" }
|
Log.d(TAG) { "stopDiscovery (registered=$registered)" }
|
||||||
main.post {
|
main.post {
|
||||||
if (!registered) return@post
|
if (!registered) return@post
|
||||||
@@ -359,14 +355,13 @@ class ChromecastCaster(
|
|||||||
CastDevice(
|
CastDevice(
|
||||||
id = route.id,
|
id = route.id,
|
||||||
name = route.name,
|
name = route.name,
|
||||||
kind = CastDeviceKind.Chromecast,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" }
|
Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" }
|
||||||
devicesFlow.value = list
|
devicesFlow.value = list
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cast(
|
suspend fun cast(
|
||||||
device: CastDevice,
|
device: CastDevice,
|
||||||
request: CastRequest,
|
request: CastRequest,
|
||||||
) {
|
) {
|
||||||
@@ -481,7 +476,7 @@ class ChromecastCaster(
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun stopCasting() {
|
suspend fun stopCasting() {
|
||||||
Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" }
|
Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" }
|
||||||
// Await MEDIA_STOP before endCurrentSession() — racing them on the
|
// Await MEDIA_STOP before endCurrentSession() — racing them on the
|
||||||
// same main-thread tick loses the stop on some receivers (LG webOS).
|
// same main-thread tick loses the stop on some receivers (LG webOS).
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ 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"
|
playServicesCast = "22.1.0"
|
||||||
vico-charts-compose = "3.1.0"
|
vico-charts-compose = "3.1.0"
|
||||||
zelory = "3.0.1"
|
zelory = "3.0.1"
|
||||||
@@ -195,12 +189,6 @@ schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr25
|
|||||||
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
|
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" }
|
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user