From 91aa72ae1e550f1bb810d9655afcdf993a943e8b Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 11 May 2026 08:34:27 +0200 Subject: [PATCH] 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 --- amethyst/build.gradle | 16 +- amethyst/proguard-rules.pro | 7 - .../cast/chromecast/ChromecastCaster.kt | 23 +- amethyst/src/main/AndroidManifest.xml | 5 - .../com/vitorpamplona/amethyst/AppModules.kt | 8 +- .../amethyst/model/UiSettings.kt | 18 - .../amethyst/model/UiSettingsFlow.kt | 9 - .../model/preferences/UISharedPreferences.kt | 4 - .../amethyst/service/cast/CastDevice.kt | 6 - .../amethyst/service/cast/CastRegistry.kt | 150 +------ .../amethyst/service/cast/VideoCaster.kt | 63 --- .../amethyst/service/cast/dlna/DlnaCaster.kt | 379 ------------------ .../playback/composable/RenderVideoPlayer.kt | 1 + .../composable/controls/PlayPauseButton.kt | 5 +- .../controls/RenderCenterButtons.kt | 34 +- .../composable/controls/RenderTopButtons.kt | 71 +++- .../composable/controls/SkipButton.kt | 9 +- .../ui/cast/CastDevicePickerDialog.kt | 10 +- .../loggedIn/settings/AppSettingsScreen.kt | 24 -- .../settings/VideoPlayerSettingsScreen.kt | 9 +- amethyst/src/main/res/values/strings.xml | 7 +- .../cast/chromecast/ChromecastCaster.kt | 21 +- gradle/libs.versions.toml | 12 - 23 files changed, 147 insertions(+), 744 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 4549d5a047..57c2f5f539 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -199,10 +199,12 @@ android { play { getIsDefault().set(true) dimension "channel" + buildConfigField "boolean", "IS_CASTING_AVAILABLE", "true" } fdroid { dimension "channel" + buildConfigField "boolean", "IS_CASTING_AVAILABLE", "false" } } @@ -379,20 +381,6 @@ dependencies { //PushNotifications(FDroid) fdroidImplementation libs.unifiedpush - // DLNA / UPnP discovery and AVTransport — used by the LAN cast feature in - // both flavors. jUPnP is the maintained fork of Cling. - implementation libs.jupnp.core - implementation libs.jupnp.android - implementation libs.jupnp.support - // jUPnP-android's AndroidUpnpServiceConfiguration.createStreamServer wires - // in JettyServletContainer (needs jetty-server + jetty-servlet) AND - // createStreamClient wires in JettyStreamClientImpl (needs jetty-client) — - // without all three jUPnP throws NoClassDefFoundError on startup() and - // DLNA discovery never starts. - implementation libs.jetty.server - implementation libs.jetty.servlet - implementation libs.jetty.client - // Google Cast SDK — Chromecast support. Play flavor only because the // framework hard-depends on Google Play services, which is unavailable // on de-Googled / GrapheneOS devices that ship the F-Droid build. diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index e55a4c2d24..6e21a524cd 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -70,10 +70,3 @@ (); } -# jUPnP ships optional Jetty + OSGi transport adapters that Android doesn't -# use (we run jupnp-android's Servlet-less transport). Tell R8 to ignore the -# dangling references rather than fail the minify step. --dontwarn org.eclipse.jetty.** --dontwarn org.osgi.** --dontwarn javax.servlet.** --dontwarn org.jupnp.transport.impl.jetty.** diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index 6ad94ea73e..61bdddc0af 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.service.cast.chromecast import android.content.Context 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow 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 * 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") class ChromecastCaster( appContext: Context, -) : VideoCaster { - override val kind: CastDeviceKind = CastDeviceKind.Chromecast +) { + val devices: StateFlow> = MutableStateFlow>(emptyList()).asStateFlow() - override val devices: StateFlow> = MutableStateFlow>(emptyList()).asStateFlow() + val sessionState: StateFlow = MutableStateFlow(CastSessionState.Idle).asStateFlow() - override val sessionState: StateFlow = MutableStateFlow(CastSessionState.Idle).asStateFlow() + fun startDiscovery() = Unit - override fun startDiscovery() = Unit + fun stopDiscovery() = Unit - override fun stopDiscovery() = Unit - - override suspend fun cast( + suspend fun cast( device: CastDevice, request: CastRequest, ) = Unit - override suspend fun stopCasting() = Unit + suspend fun stopCasting() = Unit } diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 9c914026f2..f49058bee7 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -42,11 +42,6 @@ (otherwise the wrapper waits ~30s for QUIC PTO before reconnecting). --> - - - - diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index cc358f57b0..466779ad6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -493,12 +493,12 @@ class AppModules( Nip95CacheFactory.new(appContext) } - // LAN cast registry — aggregates Chromecast (play flavor only) and DLNA - // discovery into a single device list. Discovery starts only when the - // picker dialog opens; idle by default to keep multicast traffic off. + // LAN cast registry — Chromecast only (play flavor real, fdroid no-op + // stub). Discovery starts only when the picker dialog opens; idle by + // default to keep multicast traffic off. val castRegistry: CastRegistry by lazy { Log.d("AppModules", "CastRegistry Init") - CastRegistry(appContext, applicationIOScope, uiPrefs.value.castProtocol) + CastRegistry(appContext) } // local video cache with disk + memory diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 2b043bb775..e1ebf5fa8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -47,7 +47,6 @@ data class UiSettings( val showHomeNewThreadsTab: Boolean = true, val showHomeConversationsTab: Boolean = true, val showHomeEverythingTab: Boolean = false, - val castProtocol: CastProtocolType = CastProtocolType.BOTH, ) enum class ThemeType( @@ -148,23 +147,6 @@ fun parseBooleanType(screenCode: Int): BooleanType = else -> BooleanType.ALWAYS } -enum class CastProtocolType( - val screenCode: Int, - val resourceId: Int, -) { - BOTH(0, R.string.cast_protocol_both), - CHROMECAST(1, R.string.cast_protocol_chromecast), - DLNA(2, R.string.cast_protocol_dlna), -} - -fun parseCastProtocolType(screenCode: Int): CastProtocolType = - when (screenCode) { - CastProtocolType.BOTH.screenCode -> CastProtocolType.BOTH - CastProtocolType.CHROMECAST.screenCode -> CastProtocolType.CHROMECAST - CastProtocolType.DLNA.screenCode -> CastProtocolType.DLNA - else -> CastProtocolType.BOTH - } - enum class WarningType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index ef96a8b970..8a57956362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -47,7 +47,6 @@ class UiSettingsFlow( val showHomeNewThreadsTab: MutableStateFlow = MutableStateFlow(true), val showHomeConversationsTab: MutableStateFlow = MutableStateFlow(true), val showHomeEverythingTab: MutableStateFlow = MutableStateFlow(false), - val castProtocol: MutableStateFlow = MutableStateFlow(CastProtocolType.BOTH), ) { val listOfFlows: List> = listOf>( @@ -69,7 +68,6 @@ class UiSettingsFlow( showHomeNewThreadsTab, showHomeConversationsTab, showHomeEverythingTab, - castProtocol, ) // emits at every change in any of the propertyes. @@ -95,7 +93,6 @@ class UiSettingsFlow( flows[15] as Boolean, flows[16] as Boolean, flows[17] as Boolean, - flows[18] as CastProtocolType, ) } @@ -119,7 +116,6 @@ class UiSettingsFlow( showHomeNewThreadsTab.value, showHomeConversationsTab.value, showHomeEverythingTab.value, - castProtocol.value, ) fun update(torSettings: UiSettings): Boolean { @@ -197,10 +193,6 @@ class UiSettingsFlow( showHomeEverythingTab.tryEmit(torSettings.showHomeEverythingTab) any = true } - if (castProtocol.value != torSettings.castProtocol) { - castProtocol.tryEmit(torSettings.castProtocol) - any = true - } return any } @@ -238,7 +230,6 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.showHomeNewThreadsTab), MutableStateFlow(uiSettings.showHomeConversationsTab), MutableStateFlow(uiSettings.showHomeEverythingTab), - MutableStateFlow(uiSettings.castProtocol), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index b31cd8c606..28be3d53e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -32,7 +32,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.model.CastProtocolType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.ProfileGalleryType @@ -109,7 +108,6 @@ class UiSharedPreferences( val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items") - val UI_CAST_PROTOCOL = stringPreferencesKey("ui.cast_protocol") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -136,7 +134,6 @@ class UiSharedPreferences( preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems, - castProtocol = preferences[UI_CAST_PROTOCOL]?.let { runCatching { CastProtocolType.valueOf(it) }.getOrNull() } ?: CastProtocolType.BOTH, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -176,7 +173,6 @@ class UiSharedPreferences( preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name } - preferences[UI_CAST_PROTOCOL] = sharedSettings.castProtocol.name } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index 089d7d1569..670d34d339 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -22,16 +22,10 @@ package com.vitorpamplona.amethyst.service.cast import androidx.compose.runtime.Immutable -enum class CastDeviceKind { - Chromecast, - Dlna, -} - @Immutable data class CastDevice( val id: String, val name: String, - val kind: CastDeviceKind, ) @Immutable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt index 648c97caae..3c15c243fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRegistry.kt @@ -21,138 +21,36 @@ package com.vitorpamplona.amethyst.service.cast import android.content.Context -import com.vitorpamplona.amethyst.model.CastProtocolType import com.vitorpamplona.amethyst.service.cast.chromecast.ChromecastCaster -import com.vitorpamplona.amethyst.service.cast.dlna.DlnaCaster import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.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.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger private const val TAG = "CastRegistry" /** - * Aggregates every [VideoCaster] implementation available on this build into - * a single device list and a single active-session view, so the cast UI - * doesn't care whether a device speaks Chromecast or DLNA. + * Thin wrapper around the platform-flavor [ChromecastCaster]. Ref-counts + * discovery starts/stops so multiple UI callers (picker dialog open + cast + * tap) can coordinate without tearing the underlying scan down mid-cast. * - * Discovery is started/stopped explicitly by callers (typically the picker - * dialog's DisposableEffect) so that SSDP multicast traffic + the - * MulticastLock are only paid for while the user is choosing. - * - * The registry honours [protocolFlow]: when the user has narrowed cast to a - * single protocol, the disabled caster never starts discovery and its devices - * are filtered out of [devices]. Switching protocols mid-session leaves any - * active cast running — only future discovery is affected. + * On fdroid the wrapped [ChromecastCaster] is a no-op stub — the registry + * still constructs and answers cleanly, but discovery returns no devices. + * Casting is hidden entirely on fdroid via `BuildConfig.IS_CASTING_AVAILABLE`. */ class CastRegistry( appContext: Context, - scope: CoroutineScope, - private val protocolFlow: StateFlow = - MutableStateFlow(CastProtocolType.BOTH), ) { - private val casters: List = - listOf( - ChromecastCaster(appContext), - DlnaCaster(appContext), - ) + private val caster = ChromecastCaster(appContext) - val devices: StateFlow> = - combine( - combine(casters.map { it.devices }) { snapshots -> snapshots.toList() }, - protocolFlow, - ) { snapshots, proto -> - val merged = - snapshots.flatMapIndexed { index, list -> - if (isCasterEnabled(casters[index], proto)) list else emptyList() - } - Log.d(TAG) { - "devices flow update proto=$proto " + - snapshots.mapIndexed { i, l -> "${casters[i].kind}=${l.size}" }.joinToString() + - " merged=${merged.size} -> [${merged.joinToString { "${it.kind}:${it.name}" }}]" - } - merged - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - val sessionState: StateFlow = - combine(casters.map { it.sessionState }) { states -> - states.firstOrNull { it !is CastSessionState.Idle } ?: CastSessionState.Idle - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), CastSessionState.Idle) + val devices: StateFlow> = caster.devices + val sessionState: StateFlow = caster.sessionState private val refCount = AtomicInteger(0) - private val active = - ConcurrentHashMap().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() { val before = refCount.getAndIncrement() Log.d(TAG) { "startDiscovery refCount $before -> ${before + 1}" } - if (before == 0) reconcile() + if (before == 0) caster.startDiscovery() } fun stopDiscovery() { @@ -160,7 +58,7 @@ class CastRegistry( Log.d(TAG) { "stopDiscovery refCount -> $after" } if (after <= 0) { refCount.set(0) - stopAll() + caster.stopDiscovery() } } @@ -168,32 +66,12 @@ class CastRegistry( device: CastDevice, request: CastRequest, ) { - Log.d(TAG) { - "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 - } + Log.d(TAG) { "cast device=${device.name} url=${request.url} mime=${request.mimeType}" } caster.cast(device, request) } suspend fun stopCasting() { - Log.d(TAG) { "stopCasting (broadcast to all casters)" } - coroutineScope { - 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() - } + Log.d(TAG) { "stopCasting" } + caster.stopCasting() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt deleted file mode 100644 index 343811f326..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/VideoCaster.kt +++ /dev/null @@ -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> - - /** Status of the active casting session owned by this caster. */ - val sessionState: StateFlow - - /** - * Called by the registry while at least one observer wants device updates - * (typically: the picker dialog is open, or a session is active). - * Implementations should be idempotent. - */ - fun startDiscovery() - - /** Counterpart to [startDiscovery]. Idempotent; no-op when not running. */ - fun stopDiscovery() - - /** - * Hand a video off to [device] and start playback. Updates [sessionState] - * to Connecting → Casting (or Error). [device] must be one this caster - * emitted in [devices]. - */ - suspend fun cast( - device: CastDevice, - request: CastRequest, - ) - - /** End the active session if any. Idempotent. */ - suspend fun stopCasting() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt deleted file mode 100644 index b712c1330c..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/dlna/DlnaCaster.kt +++ /dev/null @@ -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>(emptyList()) - override val devices: StateFlow> = devicesFlow.asStateFlow() - - private val sessionFlow = MutableStateFlow(CastSessionState.Idle) - override val sessionState: StateFlow = sessionFlow.asStateFlow() - - private val knownDevices = ConcurrentHashMap() - private var multicastLock: WifiManager.MulticastLock? = null - - @Volatile - private var upnpService: UpnpService? = null - - private val listener = - object : RegistryListener { - override fun remoteDeviceDiscoveryStarted( - registry: Registry, - device: RemoteDevice, - ) { - Log.d(TAG) { "discoveryStarted udn=${device.identity.udn} type=${device.type}" } - } - - override fun remoteDeviceDiscoveryFailed( - registry: Registry, - device: RemoteDevice, - ex: Exception?, - ) { - Log.w(TAG, "discoveryFailed udn=${device.identity.udn} type=${device.type} ex=${ex?.message}", ex) - } - - override fun remoteDeviceAdded( - registry: Registry, - device: RemoteDevice, - ) { - val isRenderer = device.type.implementsVersion(MEDIA_RENDERER) - Log.d(TAG) { - "remoteDeviceAdded udn=${device.identity.udn} type=${device.type} renderer=$isRenderer name=${device.details?.friendlyName}" - } - if (isRenderer) addDevice(device) - } - - override fun remoteDeviceUpdated( - registry: Registry, - device: RemoteDevice, - ) { - if (device.type.implementsVersion(MEDIA_RENDERER)) addDevice(device) - } - - override fun remoteDeviceRemoved( - registry: Registry, - device: RemoteDevice, - ) { - Log.d(TAG) { "remoteDeviceRemoved udn=${device.identity.udn}" } - removeDevice(device.identity.udn.identifierString) - } - - override fun localDeviceAdded( - registry: Registry, - device: LocalDevice, - ) = Unit - - override fun localDeviceRemoved( - registry: Registry, - device: LocalDevice, - ) = Unit - - override fun beforeShutdown(registry: Registry) = Unit - - override fun afterShutdown() = Unit - } - - private fun addDevice(device: RemoteDevice) { - val udn = device.identity.udn.identifierString - knownDevices[udn] = device - publish() - } - - private fun removeDevice(udn: String) { - knownDevices.remove(udn) - publish() - } - - private fun publish() { - devicesFlow.value = - knownDevices.values.map { device -> - CastDevice( - id = device.identity.udn.identifierString, - name = device.details?.friendlyName ?: device.displayString, - kind = CastDeviceKind.Dlna, - ) - } - } - - @Synchronized - override fun startDiscovery() { - Log.d(TAG) { "startDiscovery (already running? ${upnpService != null})" } - if (upnpService != null) return - try { - acquireMulticastLock() - val service = UpnpServiceImpl(AndroidUpnpServiceConfiguration()) - service.startup() - service.registry.addListener(listener) - // Republish anything already in the registry (covers reuse). - val preexisting = - service.registry.remoteDevices - .filter { it.type.implementsVersion(MEDIA_RENDERER) } - Log.d(TAG) { "startDiscovery: preexisting renderers=${preexisting.size}" } - preexisting.forEach { addDevice(it) } - service.controlPoint.search(STAllHeader()) - upnpService = service - Log.d(TAG) { "startDiscovery: jUPnP started, SSDP M-SEARCH sent (multicastLock held=${multicastLock?.isHeld})" } - } catch (t: Throwable) { - Log.w(TAG, "startDiscovery failed: ${t.message}", t) - releaseMulticastLock() - } - } - - @Synchronized - override fun stopDiscovery() { - Log.d(TAG) { "stopDiscovery (running? ${upnpService != null})" } - val service = upnpService ?: return - try { - service.registry.removeListener(listener) - service.shutdown() - } catch (t: Throwable) { - Log.w(TAG, "stopDiscovery failed: ${t.message}", t) - } - upnpService = null - knownDevices.clear() - publish() - releaseMulticastLock() - Log.d(TAG) { "stopDiscovery: jUPnP stopped, multicast lock released" } - } - - private fun acquireMulticastLock() { - if (multicastLock != null) return - try { - val wifi = appContext.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager - if (wifi == null) { - Log.w(TAG, "acquireMulticastLock: WifiManager unavailable") - return - } - val lock = wifi.createMulticastLock("amethyst-dlna-cast") - lock.setReferenceCounted(false) - lock.acquire() - multicastLock = lock - Log.d(TAG) { "acquireMulticastLock: held=${lock.isHeld}" } - } catch (t: Throwable) { - Log.w(TAG, "acquireMulticastLock failed: ${t.message}", t) - } - } - - private fun releaseMulticastLock() { - try { - multicastLock?.takeIf { it.isHeld }?.release() - } catch (t: Throwable) { - Log.w(TAG, "releaseMulticastLock failed: ${t.message}", t) - } - multicastLock = null - } - - override suspend fun cast( - device: CastDevice, - request: CastRequest, - ) { - Log.d(TAG) { "cast device=${device.name} udn=${device.id} url=${request.url}" } - val service = upnpService - if (service == null) { - Log.w(TAG, "cast: jUPnP service not running") - sessionFlow.value = CastSessionState.Error(device, "DLNA service not running") - return - } - val remoteDevice = knownDevices[device.id] ?: service.registry.getRemoteDevice(UDN.valueOf(device.id), true) - if (remoteDevice == null) { - Log.w(TAG, "cast: device ${device.id} not in registry") - sessionFlow.value = CastSessionState.Error(device, "Device went offline") - return - } - val avTransport = remoteDevice.findService(AV_TRANSPORT) - if (avTransport == null) { - Log.w(TAG, "cast: device ${device.id} has no AVTransport service") - sessionFlow.value = CastSessionState.Error(device, "Device does not advertise AVTransport") - return - } - - sessionFlow.value = CastSessionState.Connecting(device) - - val metadata = buildDidlMetadata(request) - Log.d(TAG) { "cast: SetAVTransportURI -> ${request.url} (didl=${metadata.length}b)" } - - val setUriDone = CompletableDeferred() - service.controlPoint.execute( - object : SetAVTransportURI(avTransport, request.url, metadata) { - override fun success(invocation: ActionInvocation<*>?) { - setUriDone.complete(true) - } - - override fun failure( - invocation: ActionInvocation<*>?, - operation: UpnpResponse?, - defaultMsg: String?, - ) { - Log.w(TAG, "SetAVTransportURI failed: $defaultMsg") - setUriDone.complete(false) - } - }, - ) - - val uriOk = setUriDone.await() - if (!uriOk) { - sessionFlow.value = CastSessionState.Error(device, "Receiver rejected the media URL") - return - } - - val playDone = CompletableDeferred() - service.controlPoint.execute( - object : Play(avTransport) { - override fun success(invocation: ActionInvocation<*>?) { - playDone.complete(true) - } - - override fun failure( - invocation: ActionInvocation<*>?, - operation: UpnpResponse?, - defaultMsg: String?, - ) { - Log.w(TAG, "Play failed: $defaultMsg") - playDone.complete(false) - } - }, - ) - - if (playDone.await()) { - Log.d(TAG) { "cast: Play succeeded" } - sessionFlow.value = CastSessionState.Casting(device, request) - } else { - Log.w(TAG, "cast: Play action rejected by receiver") - sessionFlow.value = CastSessionState.Error(device, "Receiver rejected Play action") - } - } - - override suspend fun stopCasting() { - Log.d(TAG) { "stopCasting (running? ${upnpService != null}, state=${sessionFlow.value::class.simpleName})" } - val service = - upnpService ?: run { - sessionFlow.value = CastSessionState.Idle - return - } - val current = sessionFlow.value - val device = - when (current) { - is CastSessionState.Casting -> { - current.device - } - - is CastSessionState.Connecting -> { - current.device - } - - else -> { - sessionFlow.value = CastSessionState.Idle - return - } - } - val remoteDevice = knownDevices[device.id] - val avTransport = remoteDevice?.findService(AV_TRANSPORT) - if (avTransport != null) { - val done = CompletableDeferred() - service.controlPoint.execute( - object : Stop(avTransport) { - override fun success(invocation: ActionInvocation<*>?) { - done.complete(Unit) - } - - override fun failure( - invocation: ActionInvocation<*>?, - operation: UpnpResponse?, - defaultMsg: String?, - ) { - done.complete(Unit) - } - }, - ) - done.await() - } - sessionFlow.value = CastSessionState.Idle - } - - private fun buildDidlMetadata(request: CastRequest): String = - try { - val mime = request.effectiveMimeType() - val res = Res(ProtocolInfo("http-get:*:$mime:*"), null as Long?, request.url) - val item = - VideoItem( - // id = - "amethyst-cast-1", - // parentID = - "0", - // title = - request.title ?: "Amethyst", - // creator = - "Amethyst", - res, - ) - val didl = DIDLContent() - didl.addItem(item) - DIDLParser().generate(didl) - } catch (t: Throwable) { - Log.w(TAG, "Could not build DIDL metadata, sending empty: ${t.message}", t) - "" - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index c86b117e41..ff17fd2830 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -154,6 +154,7 @@ fun RenderVideoPlayer( RenderCenterButtons( controllerState = controllerState, controllerVisible = controllerVisible, + videoUri = mediaItem.src.videoUri, modifier = Modifier.align(Alignment.Center), isLiveStream = isLive, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt index edd1f08fa8..4addbc6320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt @@ -81,6 +81,7 @@ fun AnimatedPlayPauseButton( controllerVisible: State, modifier: Modifier = Modifier, isPlaying: Boolean, + enabled: Boolean = true, onClick: () -> Unit, ) { AnimatedVisibility( @@ -89,13 +90,14 @@ fun AnimatedPlayPauseButton( enter = remember { fadeIn() }, exit = remember { fadeOut() }, ) { - PlayPauseButton(isPlaying, onClick) + PlayPauseButton(isPlaying, enabled, onClick) } } @Composable fun PlayPauseButton( isPlaying: Boolean, + enabled: Boolean = true, onClick: () -> Unit, ) { Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) { @@ -108,6 +110,7 @@ fun PlayPauseButton( IconButton( onClick = onClick, + enabled = enabled, modifier = Modifier.size(80.dp), ) { if (!isPlaying) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt index fd28d1aa5e..676ef286e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -26,11 +26,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi 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.seekBackward import com.vitorpamplona.amethyst.service.playback.composable.skipForward @@ -41,34 +45,56 @@ import kotlinx.coroutines.delay fun RenderCenterButtons( controllerState: MediaControllerState, controllerVisible: MutableState, + videoUri: String, modifier: Modifier, isLiveStream: Boolean = false, ) { val state = rememberPlayPauseButtonState(controllerState.controller) + val castSessionState by Amethyst.instance.castRegistry.sessionState + .collectAsStateWithLifecycle() + val isCastingThisVideo = + (castSessionState as? CastSessionState.Casting)?.request?.url == videoUri + Row( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, ) { if (!isLiveStream) { - AnimatedSkipButton(controllerVisible = controllerVisible, isForward = false) { + AnimatedSkipButton( + controllerVisible = controllerVisible, + isForward = false, + enabled = !isCastingThisVideo, + ) { controllerState.controller.seekBackward() } } - AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) { + AnimatedPlayPauseButton( + controllerVisible, + Modifier, + !state.showPlay, + enabled = !isCastingThisVideo, + ) { state.onClick() } if (!isLiveStream) { - AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) { + AnimatedSkipButton( + controllerVisible = controllerVisible, + isForward = true, + enabled = !isCastingThisVideo, + ) { 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) { delay(3000) controllerVisible.value = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 7c14154d60..843b83f01e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -50,6 +50,7 @@ import androidx.media3.common.Player import androidx.media3.common.Tracks import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol @@ -143,19 +144,39 @@ fun RenderTopButtons( val overflowQualityOpen = remember { mutableStateOf(false) } // 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 .collectAsStateWithLifecycle() - val wasCastingThisVideo = remember { mutableStateOf(false) } - LaunchedEffect(castSessionStateForLocal, mediaData.videoUri) { - val isCastingThis = - (castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri - if (isCastingThis && !wasCastingThisVideo.value) { + val isCastingThisVideo = + (castSessionStateForLocal as? CastSessionState.Casting)?.request?.url == mediaData.videoUri + DisposableEffect(player, isCastingThisVideo) { + if (isCastingThisVideo) { 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() } - wasCastingThisVideo.value = isCastingThis + previousCasting.value = isCastingThisVideo } RenderTopButtons( @@ -247,13 +268,33 @@ fun RenderTopButtons( fun isAvailable(action: VideoPlayerAction): Boolean = when (action) { - VideoPlayerAction.Fullscreen -> onZoomClick != null - VideoPlayerAction.Mute -> true - VideoPlayerAction.Quality -> hasMultipleQualities - VideoPlayerAction.Share -> true - VideoPlayerAction.Download -> !isLive - VideoPlayerAction.PictureInPicture -> pipSupported - VideoPlayerAction.Cast -> mediaData.videoUri.startsWith("http", ignoreCase = true) + VideoPlayerAction.Fullscreen -> { + onZoomClick != null + } + + VideoPlayerAction.Mute -> { + 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt index 2285a34c08..4ede63da3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt @@ -69,6 +69,7 @@ fun AnimatedSkipButton( controllerVisible: State, isForward: Boolean, modifier: Modifier = Modifier, + enabled: Boolean = true, onClick: () -> Unit, ) { AnimatedVisibility( @@ -77,18 +78,20 @@ fun AnimatedSkipButton( enter = FadeIn, exit = FadeOut, ) { - SkipButton(isForward = isForward, onClick = onClick) + SkipButton(isForward = isForward, enabled = enabled, onClick = onClick) } } @Composable fun SkipButton( isForward: Boolean, + enabled: Boolean = true, onClick: () -> Unit, ) { 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) - IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) { - Icon(symbol = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(32.dp)) + val tint = if (enabled) Color.White else Color.White.copy(alpha = 0.38f) + IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) { + Icon(symbol = icon, contentDescription = label, tint = tint, modifier = Modifier.size(32.dp)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index 2bb0b30497..88f567d1aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -31,7 +31,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.service.cast.CastDeviceKind import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -81,16 +80,11 @@ fun CastDevicePickerDialog( } else { M3ActionSection { devices.forEach { device -> - val icon = - when (device.kind) { - CastDeviceKind.Chromecast -> MaterialSymbols.Cast - CastDeviceKind.Dlna -> MaterialSymbols.CastConnected - } M3ActionRow( - icon = icon, + icon = MaterialSymbols.Cast, 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 // session listener stays registered until the cast attempt finishes. // Without this the dialog's stopDiscovery (~10ms after tap) tears the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 805e1d80db..6dc4f3381c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -56,14 +56,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.BooleanType -import com.vitorpamplona.amethyst.model.CastProtocolType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow import com.vitorpamplona.amethyst.model.parseBooleanType -import com.vitorpamplona.amethyst.model.parseCastProtocolType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType @@ -132,7 +130,6 @@ fun SettingsScreen( ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) if (BuildConfig.FLAVOR == "play") { - CastProtocolChoice(sharedPrefs) } ShowUrlPreviewChoice(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 fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) { val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt index 5b8e921919..e6e6dada17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/VideoPlayerSettingsScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -103,7 +104,13 @@ fun VideoPlayerSettingsScreen( @Composable fun VideoPlayerSettingsContent(accountViewModel: AccountViewModel) { 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) { items = newItems.toMutableList() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 57191c6e4d..32ef42f027 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2557,16 +2557,11 @@ Playback Auto - + Cast to device Stop casting Cast to… Searching for devices on your Wi-Fi… - Cast protocol - Which devices to scan for when casting videos on your Wi-Fi - Chromecast & UPnP - Chromecast only - UPnP / DLNA only HLS Upload diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index 96beee47a4..e27b75bfda 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -39,10 +39,8 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage import com.vitorpamplona.amethyst.service.cast.CastDevice -import com.vitorpamplona.amethyst.service.cast.CastDeviceKind import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState -import com.vitorpamplona.amethyst.service.cast.VideoCaster import com.vitorpamplona.amethyst.service.cast.effectiveMimeType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CompletableDeferred @@ -58,7 +56,7 @@ private const val SESSION_START_TIMEOUT_MS = 30_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 * 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( private val appContext: Context, -) : VideoCaster { - override val kind: CastDeviceKind = CastDeviceKind.Chromecast - +) { private val devicesFlow = MutableStateFlow>(emptyList()) - override val devices: StateFlow> = devicesFlow.asStateFlow() + val devices: StateFlow> = devicesFlow.asStateFlow() private val sessionFlow = MutableStateFlow(CastSessionState.Idle) - override val sessionState: StateFlow = sessionFlow.asStateFlow() + val sessionState: StateFlow = sessionFlow.asStateFlow() private val main = Handler(Looper.getMainLooper()) private var mediaRouter: MediaRouter? = null @@ -304,7 +300,7 @@ class ChromecastCaster( Log.d(TAG) { "sessionListener attached (caster lifetime)" } } - override fun startDiscovery() { + fun startDiscovery() { Log.d(TAG) { "startDiscovery (already registered? $registered)" } main.post { if (registered) { @@ -331,7 +327,7 @@ class ChromecastCaster( } } - override fun stopDiscovery() { + fun stopDiscovery() { Log.d(TAG) { "stopDiscovery (registered=$registered)" } main.post { if (!registered) return@post @@ -359,14 +355,13 @@ class ChromecastCaster( CastDevice( id = route.id, name = route.name, - kind = CastDeviceKind.Chromecast, ) } Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" } devicesFlow.value = list } - override suspend fun cast( + suspend fun cast( device: CastDevice, request: CastRequest, ) { @@ -481,7 +476,7 @@ class ChromecastCaster( .build() } - override suspend fun stopCasting() { + suspend fun stopCasting() { Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" } // Await MEDIA_STOP before endCurrentSession() — racing them on the // same main-thread tick loses the stop on some receivers (LG webOS). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 105d115d8e..8a38983219 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,12 +65,6 @@ tarsosdsp = "2.5" translate = "17.0.3" jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" -jupnp = "3.0.4" -# jUPnP 3.0.x's AndroidUpnpServiceConfiguration.createStreamServer wires in -# JettyServletContainer at runtime — without these jUPnP throws -# NoClassDefFoundError on startup() and DLNA discovery never runs. Stay on the -# 9.4 line because jUPnP 3.0.x targets javax.servlet (Jetty 10+ uses jakarta). -jetty = "9.4.57.v20241219" playServicesCast = "22.1.0" vico-charts-compose = "3.1.0" zelory = "3.0.1" @@ -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" } tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } -jupnp-android = { group = "org.jupnp", name = "org.jupnp.android", version.ref = "jupnp" } -jupnp-core = { group = "org.jupnp", name = "org.jupnp", version.ref = "jupnp" } -jupnp-support = { group = "org.jupnp", name = "org.jupnp.support", version.ref = "jupnp" } -jetty-server = { group = "org.eclipse.jetty", name = "jetty-server", version.ref = "jetty" } -jetty-servlet = { group = "org.eclipse.jetty", name = "jetty-servlet", version.ref = "jetty" } -jetty-client = { group = "org.eclipse.jetty", name = "jetty-client", version.ref = "jetty" } play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }