From 7f64371877d26b707427805f0162e6f24686d4bd Mon Sep 17 00:00:00 2001 From: guko Date: Thu, 27 Aug 2026 15:46:30 +0800 Subject: [PATCH 1/2] Give QuantumCast a public link too; versionCode 16 QC drew the shared ListenQrDialog and always passed PublicLinkState.Off, because the tunnel lived in Telecloud's own folder. Now that it is a library module, QC gets the setting, the toggle and the wiring, and the dialog has something to show. The pieces mirror Telecloud: a tunnelEnabled DataStore key, a Public link switch under Web player with the same copy (the bandwidth cost and the fact that the link is world-reachable both belong in front of the switch), TunnelManager provided @Singleton in AppModule, and PlaybackService starting the tunnel while the setting is on and the broadcast is up. One thing QC lacked: any observable broadcast liveness. It knew only `snapserverProcess != null && snapserverJob?.isActive`, which a flow cannot watch, so PlaybackState gains isBroadcasting, set in startSnapcast and cleared in stopSnapcast - the only two places that own the snapserver's lifetime. The collector is distinctUntilChanged because _state churns on every ICY title and buffering tick. onDestroy stops the tunnel explicitly rather than relying on the collector: cancelling the scope kills the collector, so an implicit stop would leave cloudflared running. Pinned to capullo-audio ac21187 like Telecloud - one pin, three modules. --- app/build.gradle.kts | 5 +++- .../tech/capullo/quantumcast/MainActivity.kt | 15 ++++++++++ .../quantumcast/data/settings/AppSettings.kt | 5 ++++ .../tech/capullo/quantumcast/di/AppModule.kt | 8 +++++ .../quantumcast/player/PlaybackService.kt | 29 ++++++++++++++++++- .../quantumcast/ui/screens/SettingsScreen.kt | 23 +++++++++++++++ .../ui/screens/TrackDetailScreen.kt | 3 ++ .../quantumcast/viewmodel/RadioViewModel.kt | 6 ++++ gradle/pins.versions.toml | 4 ++- settings.gradle.kts | 2 ++ 10 files changed, 97 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5364c6..e8900fa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,7 @@ android { applicationId = "tech.capullo.quantumcast" minSdk = 26 targetSdk = 36 - versionCode = 15 + versionCode = 16 versionName = "1.0" } @@ -151,6 +151,9 @@ dependencies { // none of those at compile time anymore, so their former direct declarations were dropped here. implementation(pins.capullo.audio) implementation(pins.capullo.audio.ui) // shared control sheet + QR dialog + // Public-link tunnel: TunnelManager + the cloudflared .so per ABI, carried by the AAR. Opt-in, + // hence a separate coordinate: it costs ~8 MB per ABI and an app is free not to have a tunnel. + implementation(pins.capullo.tunnel) // Serialization runtime - Navigation3 `@Serializable data object … : NavKey` route keys. implementation(libs.kotlinx.serialization.json) diff --git a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt index 703a805..85590c8 100755 --- a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt +++ b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt @@ -30,6 +30,8 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.serialization.Serializable import tech.capullo.audio.snapcast.DiscoveredSnapserver import tech.capullo.audio.snapcast.SnapserverDiscoveryManager +import tech.capullo.audio.tunnel.TunnelManager +import tech.capullo.audio.ui.PublicLinkState import tech.capullo.quantumcast.player.PlaybackService import tech.capullo.quantumcast.ui.screens.* import tech.capullo.quantumcast.ui.theme.RadioTheme @@ -182,6 +184,7 @@ private fun RadioApp( val showTrackDetail by vm.showDetailScreen.collectAsState() val snapclientHost by vm.snapclientHost.collectAsState() val broadcastHttpPort by vm.broadcastHttpPort.collectAsState() + val tunnelState by vm.tunnelState.collectAsState() val snapclientChannel by vm.snapclientChannel.collectAsState() val snapcastGroups by vm.snapcastGroups.collectAsState() val streamCanGoNext by vm.streamCanGoNext.collectAsState() @@ -229,6 +232,7 @@ private fun RadioApp( streamStats = streamStats, snapcastGroups = snapcastGroups, broadcastHttpPort = broadcastHttpPort, + publicLink = tunnelState.toPublicLinkState(), snapclientChannel = snapclientChannel, onAdjustClientVolume = vm::adjustClientVolume, onAdjustClientLatency = vm::adjustClientLatency, @@ -428,6 +432,7 @@ private fun RadioApp( onSetAutoEntangleOnLaunch = { vm.updateSetting { setAutoEntangleOnLaunch(it) } }, onSetWebDebugPanel = { vm.updateSetting { setWebDebugPanel(it) } }, onSetWebAutoplay = { vm.updateSetting { setWebAutoplay(it) } }, + onSetTunnelEnabled = { vm.updateSetting { setTunnelEnabled(it) } }, onSetMaxHistorySongs = { vm.updateSetting { setMaxHistorySongs(it) } }, onExportFavorites = vm::exportFavorites, onImportFavorites = vm::importFavorites, @@ -454,3 +459,13 @@ private fun ChildTopBar(title: String, onBack: () -> Unit) { }, ) } + +// Maps the tunnel's state machine onto the library's public-link model for the sheet's QR dialog. +// Same mapping Telecloud uses - the library deliberately does not depend on TunnelManager, so each +// app bridges the two itself. +private fun TunnelManager.TunnelState.toPublicLinkState() = when (this) { + is TunnelManager.TunnelState.Active -> PublicLinkState.Active(publicUrl) + TunnelManager.TunnelState.Starting -> PublicLinkState.Starting + is TunnelManager.TunnelState.Error -> PublicLinkState.Error(message) + TunnelManager.TunnelState.Off -> PublicLinkState.Off +} diff --git a/app/src/main/java/tech/capullo/quantumcast/data/settings/AppSettings.kt b/app/src/main/java/tech/capullo/quantumcast/data/settings/AppSettings.kt index b195dbd..225f82f 100755 --- a/app/src/main/java/tech/capullo/quantumcast/data/settings/AppSettings.kt +++ b/app/src/main/java/tech/capullo/quantumcast/data/settings/AppSettings.kt @@ -35,6 +35,8 @@ data class AppSettings( val autoEntangleOnLaunch: Boolean = false, val webDebugPanel: Boolean = false, val webAutoplay: Boolean = false, + // Public link: expose the web player through a Cloudflare quick tunnel while broadcasting. + val tunnelEnabled: Boolean = false, // Stereo balance applied to the broadcast mix: -1 = full left, 0 = center, +1 = full right. val balance: Float = 0f, // This device's own snapclient spatial role - persisted so a set-once L/R + latency + volume @@ -72,6 +74,7 @@ class SettingsRepository(private val context: Context) { val AUTO_ENTANGLE_ON_LAUNCH = booleanPreferencesKey("auto_entangle_on_launch") val WEB_DEBUG_PANEL = booleanPreferencesKey("web_debug_panel") val WEB_AUTOPLAY = booleanPreferencesKey("web_autoplay") + val TUNNEL_ENABLED = booleanPreferencesKey("tunnel_enabled") val BALANCE = floatPreferencesKey("balance") val SNAPCLIENT_CHANNEL = stringPreferencesKey("snapclient_channel") val SNAPCLIENT_VOLUME = intPreferencesKey("snapclient_volume") @@ -102,6 +105,7 @@ class SettingsRepository(private val context: Context) { autoEntangleOnLaunch = prefs[Keys.AUTO_ENTANGLE_ON_LAUNCH] ?: false, webDebugPanel = prefs[Keys.WEB_DEBUG_PANEL] ?: false, webAutoplay = prefs[Keys.WEB_AUTOPLAY] ?: false, + tunnelEnabled = prefs[Keys.TUNNEL_ENABLED] ?: false, balance = prefs[Keys.BALANCE] ?: 0f, snapclientChannel = prefs[Keys.SNAPCLIENT_CHANNEL] ?: "stereo", snapclientVolume = prefs[Keys.SNAPCLIENT_VOLUME] ?: 100, @@ -129,6 +133,7 @@ class SettingsRepository(private val context: Context) { suspend fun setAutoEntangleOnLaunch(v: Boolean) = update { it[Keys.AUTO_ENTANGLE_ON_LAUNCH] = v } suspend fun setWebDebugPanel(v: Boolean) = update { it[Keys.WEB_DEBUG_PANEL] = v } suspend fun setWebAutoplay(v: Boolean) = update { it[Keys.WEB_AUTOPLAY] = v } + suspend fun setTunnelEnabled(v: Boolean) = update { it[Keys.TUNNEL_ENABLED] = v } suspend fun setBalance(v: Float) = update { it[Keys.BALANCE] = v.coerceIn(-1f, 1f) } suspend fun setSnapclientChannel(v: String) = update { it[Keys.SNAPCLIENT_CHANNEL] = v } suspend fun setSnapclientVolume(v: Int) = update { it[Keys.SNAPCLIENT_VOLUME] = v.coerceIn(0, 100) } diff --git a/app/src/main/java/tech/capullo/quantumcast/di/AppModule.kt b/app/src/main/java/tech/capullo/quantumcast/di/AppModule.kt index d54c9ee..c099026 100755 --- a/app/src/main/java/tech/capullo/quantumcast/di/AppModule.kt +++ b/app/src/main/java/tech/capullo/quantumcast/di/AppModule.kt @@ -6,6 +6,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import tech.capullo.audio.tunnel.TunnelManager import tech.capullo.quantumcast.data.settings.SettingsRepository import tech.capullo.source.radiobrowser.data.repository.RadioRepository import javax.inject.Singleton @@ -29,4 +30,11 @@ object AppModule { @Provides @Singleton fun provideSettingsRepository(@ApplicationContext context: Context): SettingsRepository = SettingsRepository(context) + + // The library ships TunnelManager without DI annotations so it forces no framework on a + // consumer; @Singleton here is what keeps it to one instance - and so to one cloudflared + // process - now that Hilt no longer reads that off the class. + @Provides + @Singleton + fun provideTunnelManager(@ApplicationContext context: Context): TunnelManager = TunnelManager(context) } diff --git a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt index e162c8e..47de9d5 100755 --- a/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt +++ b/app/src/main/java/tech/capullo/quantumcast/player/PlaybackService.kt @@ -51,6 +51,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive @@ -67,6 +69,7 @@ import tech.capullo.audio.snapcast.SnapserverPorts import tech.capullo.audio.snapcast.SnapserverProcess import tech.capullo.audio.snapcast.firstArtist import tech.capullo.audio.snapcast.withoutReferenceTaps +import tech.capullo.audio.tunnel.TunnelManager import tech.capullo.quantumcast.MainActivity import tech.capullo.quantumcast.data.settings.BroadcastMode import tech.capullo.quantumcast.data.settings.SettingsRepository @@ -82,6 +85,9 @@ class PlaybackService : Service() { @Inject lateinit var settingsRepository: SettingsRepository + @Inject + lateinit var tunnelManager: TunnelManager + // --- State exposed to ViewModel --- data class PlaybackState( @@ -99,6 +105,13 @@ class PlaybackService : Service() { val snapclientPort: Int = 1604, /** This broadcaster's resolved HTTP (web player + control) port - for the web/QR URL. */ val broadcastHttpPort: Int = 1680, + /** + * Snapserver up and serving, so broadcastHttpPort is real. QC had no such flag - broadcast + * liveness lived only in `snapserverProcess != null && snapserverJob?.isActive`, which a + * flow cannot observe. Set in startSnapcast, cleared in stopSnapcast: the only two places + * that own the snapserver's lifetime. + */ + val isBroadcasting: Boolean = false, val snapclientChannel: String = "stereo", val snapclientState: tech.capullo.audio.snapcast.SnapclientProcess.ConnectionState = tech.capullo.audio.snapcast.SnapclientProcess.ConnectionState.STARTING, @@ -992,6 +1005,16 @@ class PlaybackService : Service() { snapserverFixedPort = it.snapserverFixedPort } } + // Public-link tunnel: runs only while the snapserver (and thus the web player's HTTP port) + // is live and the setting is on. distinctUntilChanged because _state churns on every ICY + // title and buffering tick, and start() is idempotent but stop() is not free. + scope.launch { + combine(settingsRepository.settings, _state) { settings, s -> + Triple(settings.tunnelEnabled, s.isBroadcasting, s.broadcastHttpPort) + }.distinctUntilChanged().collect { (enabled, broadcasting, httpPort) -> + if (enabled && broadcasting) tunnelManager.start(httpPort) else tunnelManager.stop() + } + } // Persist own client's volume/latency on ANY change (slider, knob, remote controller). // GetStatus-only observation missed incremental ClientOnVolumeChanged updates, so this // watches state directly. Gated by volLatRestored so the connect-time default can't be @@ -1946,7 +1969,7 @@ class PlaybackService : Service() { private fun startSnapcast(snapserver: SnapserverProcess, snapserverAddress: String) { val ports = snapserver.ports - _state.update { it.copy(broadcastHttpPort = ports.httpPort) } + _state.update { it.copy(broadcastHttpPort = ports.httpPort, isBroadcasting = true) } val sc = SnapclientProcess(this).also { snapclientProcess = it } snapserverJob = scope.launch { snapserver.start() } snapclientJob = scope.launch { sc.start(snapserverAddress, ports.streamPort) } @@ -2215,6 +2238,7 @@ class PlaybackService : Service() { snapserverJob = null stopLocalSnapclient() snapserverProcess = null + _state.update { it.copy(isBroadcasting = false) } localChannelTagSet = false volLatRestored = false volLatApplied = false @@ -2417,6 +2441,9 @@ class PlaybackService : Service() { override fun onDestroy() { stop() + // Explicitly, not via the collector: cancelling `scope` below kills the collector, so + // relying on it to observe isBroadcasting=false would leave cloudflared running. + tunnelManager.stop() mediaSession?.release() mediaSession = null serviceJob.cancel() diff --git a/app/src/main/java/tech/capullo/quantumcast/ui/screens/SettingsScreen.kt b/app/src/main/java/tech/capullo/quantumcast/ui/screens/SettingsScreen.kt index 414a4c7..996f592 100755 --- a/app/src/main/java/tech/capullo/quantumcast/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/tech/capullo/quantumcast/ui/screens/SettingsScreen.kt @@ -49,6 +49,7 @@ fun SettingsScreen( onSetAutoEntangleOnLaunch: (Boolean) -> Unit = {}, onSetWebDebugPanel: (Boolean) -> Unit = {}, onSetWebAutoplay: (Boolean) -> Unit = {}, + onSetTunnelEnabled: (Boolean) -> Unit = {}, onSetMaxHistorySongs: (Int) -> Unit = {}, onExportFavorites: (java.io.OutputStream) -> Unit = {}, onImportFavorites: (java.io.InputStream) -> Unit = {}, @@ -201,6 +202,28 @@ fun SettingsScreen( ) } + // Copy matches Telecloud's - same feature, same warnings. The bandwidth figure is per + // listener and the link is world-reachable, so both belong in front of the switch. + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Public link", style = MaterialTheme.typography.bodyMedium) + Text( + "Expose the web player on the internet via a tunnel while broadcasting " + + "(link shows in the player). Anyone with the link can listen and " + + "control; ~1.4 Mbps upload per listener.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = settings.tunnelEnabled, onCheckedChange = onSetTunnelEnabled) + } + } + // --- QuantumCast-specific sections --- item { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) } item { SectionHeader("Radio Browser API") } diff --git a/app/src/main/java/tech/capullo/quantumcast/ui/screens/TrackDetailScreen.kt b/app/src/main/java/tech/capullo/quantumcast/ui/screens/TrackDetailScreen.kt index a531ab2..da2e0a7 100755 --- a/app/src/main/java/tech/capullo/quantumcast/ui/screens/TrackDetailScreen.kt +++ b/app/src/main/java/tech/capullo/quantumcast/ui/screens/TrackDetailScreen.kt @@ -80,6 +80,7 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import kotlinx.coroutines.launch import tech.capullo.audio.snapcast.Group +import tech.capullo.audio.ui.PublicLinkState import tech.capullo.audio.ui.SnapcastControlSheet import tech.capullo.quantumcast.R import tech.capullo.quantumcast.viewmodel.PlayerState @@ -129,6 +130,7 @@ fun TrackDetailScreen( streamStats: RadioViewModel.StreamStats? = null, snapcastGroups: List = emptyList(), broadcastHttpPort: Int = 1680, + publicLink: PublicLinkState = PublicLinkState.Off, snapclientChannel: String = "stereo", onAdjustClientVolume: (clientId: String, muted: Boolean, percent: Int) -> Unit = { _, _, _ -> }, onAdjustClientLatency: (clientId: String, latencyMs: Int) -> Unit = { _, _ -> }, @@ -439,6 +441,7 @@ fun TrackDetailScreen( calibrationRunning = calibrationRunning, calibrationStatus = calibrationStatus, httpPort = broadcastHttpPort, + publicLink = publicLink, onDismiss = { showSnapcastSheet = false }, ) } diff --git a/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt b/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt index 5953b25..3f0e492 100755 --- a/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt +++ b/app/src/main/java/tech/capullo/quantumcast/viewmodel/RadioViewModel.kt @@ -15,6 +15,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +import tech.capullo.audio.tunnel.TunnelManager import tech.capullo.quantumcast.data.settings.AppSettings import tech.capullo.quantumcast.data.settings.SettingsRepository import tech.capullo.quantumcast.player.PlaybackService @@ -79,9 +80,14 @@ data class RotationState( class RadioViewModel @Inject constructor( private val repo: RadioRepository, val settingsRepo: SettingsRepository, + tunnelManager: TunnelManager, @ApplicationContext private val context: Context, ) : ViewModel() { + // The tunnel's own state, straight from the manager - it is not part of PlaybackState, because + // the tunnel outlives no broadcast and the manager is the single source of truth for it. + val tunnelState: StateFlow = tunnelManager.state + val settings: StateFlow = settingsRepo.settings .onEach { repo.setServerUrl(it.apiServer) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings()) diff --git a/gradle/pins.versions.toml b/gradle/pins.versions.toml index 32ee704..98dfb38 100644 --- a/gradle/pins.versions.toml +++ b/gradle/pins.versions.toml @@ -7,7 +7,7 @@ # jitpack caches -SNAPSHOT unreliably). Composite build substitutes the sibling locally. capulloSourceRadiobrowser = "ce836da8fefc03f1e12f83910738217b1df24aa0" # capullo-audio - immutable jitpack commit; the engine + capullo-audio-ui share it. -capulloAudio = "bbf15845912518127eb5b05ae3ac5ba908992549" +capulloAudio = "ac21187a44bb1e3cb9991e49d95e10c1a013bf1e" # QC's own single-app deps (used by no other repo). media = "1.7.0" coil = "2.6.0" @@ -24,6 +24,8 @@ capullo-source-radiobrowser = { module = "com.github.capullo-tech:capullo-source # it twice (flat direct + nested transitive via -ui) and fails on duplicate classes. capullo-audio = { module = "com.github.capullo-tech.capullo-audio:capullo-audio", version.ref = "capulloAudio" } capullo-audio-ui = { module = "com.github.capullo-tech.capullo-audio:capullo-audio-ui", version.ref = "capulloAudio" } +# Same repo, same commit, same nested group - one pin covers all three modules. +capullo-tunnel = { module = "com.github.capullo-tech.capullo-audio:capullo-tunnel", version.ref = "capulloAudio" } # QC single-app deps. androidx-media = { module = "androidx.media:media", version.ref = "media" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3f970e6..49be8ca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,8 @@ if (file("../capullo-audio").exists()) { .using(project(":capullo-audio")) substitute(module("com.github.capullo-tech.capullo-audio:capullo-audio-ui")) .using(project(":capullo-audio-ui")) + substitute(module("com.github.capullo-tech.capullo-audio:capullo-tunnel")) + .using(project(":capullo-tunnel")) } } } From e406fb6ccac601d684b9adbbab2edf394d387e74 Mon Sep 17 00:00:00 2001 From: guko Date: Thu, 27 Aug 2026 15:55:21 +0800 Subject: [PATCH 2/2] Add a dbg hook for the public-link toggle The switch is otherwise UI-only, so the rig cannot exercise the tunnel without tapping through Settings. Mirrors fixport, and is gated to debuggable builds like every other dbg hook. am start -n tech.capullo.quantumcast/.MainActivity --es dbg tunnel --ez on true --- app/src/main/java/tech/capullo/quantumcast/MainActivity.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt index 85590c8..3111d1f 100755 --- a/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt +++ b/app/src/main/java/tech/capullo/quantumcast/MainActivity.kt @@ -130,6 +130,9 @@ class MainActivity : ComponentActivity() { intent.getIntExtra("settle", 7_000).toLong(), intent.getIntExtra("capture", 12_000), ) + // Toggle the public-link tunnel without the UI, so the rig can test it. + // am start ... --es dbg tunnel --ez on true + "tunnel" -> vm.updateSetting { setTunnelEnabled(intent.getBooleanExtra("on", true)) } // Fix the snapserver base port (--ei port N; omit or -1 to auto-pick, 0 to disable). // Applies on the next broadcast start. am start ... --es dbg fixport --ei port 34000 "fixport" -> vm.setSnapserverFixedPort(intent.getIntExtra("port", -1))