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:
davotoula
2026-05-11 08:34:27 +02:00
parent 725de43c0a
commit 91aa72ae1e
23 changed files with 147 additions and 744 deletions
+2 -14
View File
@@ -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.
-7
View File
@@ -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.**
@@ -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
} }
-5
View File
@@ -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),
) )
} }
} }
@@ -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 {
casters
.map { caster ->
async {
try {
caster.stopCasting() 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)
""
}
}
@@ -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,
) )
@@ -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) {
@@ -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
@@ -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) {
val isCastingThis =
(castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri (castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri
if (isCastingThis && !wasCastingThisVideo.value) { DisposableEffect(player, isCastingThisVideo) {
if (isCastingThisVideo) {
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
@@ -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
@@ -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()
@@ -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()
+1 -6
View File
@@ -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 &amp; UPnP</string>
<string name="cast_protocol_chromecast">Chromecast only</string>
<string name="cast_protocol_dlna">UPnP / DLNA only</string>
<!-- HLS multi-resolution video sharing --> <!-- HLS multi-resolution video sharing -->
<string name="share_hls_video">HLS Upload</string> <string name="share_hls_video">HLS Upload</string>
@@ -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).
-12
View File
@@ -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" }