Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ android {
applicationId = "tech.capullo.quantumcast"
minSdk = 26
targetSdk = 36
versionCode = 15
versionCode = 16
versionName = "1.0"
}

Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/tech/capullo/quantumcast/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,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))
Expand Down Expand Up @@ -182,6 +187,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()
Expand Down Expand Up @@ -229,6 +235,7 @@ private fun RadioApp(
streamStats = streamStats,
snapcastGroups = snapcastGroups,
broadcastHttpPort = broadcastHttpPort,
publicLink = tunnelState.toPublicLinkState(),
snapclientChannel = snapclientChannel,
onAdjustClientVolume = vm::adjustClientVolume,
onAdjustClientLatency = vm::adjustClientLatency,
Expand Down Expand Up @@ -428,6 +435,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,
Expand All @@ -454,3 +462,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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) }
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/tech/capullo/quantumcast/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -82,6 +85,9 @@ class PlaybackService : Service() {
@Inject
lateinit var settingsRepository: SettingsRepository

@Inject
lateinit var tunnelManager: TunnelManager

// --- State exposed to ViewModel ---

data class PlaybackState(
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -2215,6 +2238,7 @@ class PlaybackService : Service() {
snapserverJob = null
stopLocalSnapclient()
snapserverProcess = null
_state.update { it.copy(isBroadcasting = false) }
localChannelTagSet = false
volLatRestored = false
volLatApplied = false
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -129,6 +130,7 @@ fun TrackDetailScreen(
streamStats: RadioViewModel.StreamStats? = null,
snapcastGroups: List<Group> = 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 = { _, _ -> },
Expand Down Expand Up @@ -439,6 +441,7 @@ fun TrackDetailScreen(
calibrationRunning = calibrationRunning,
calibrationStatus = calibrationStatus,
httpPort = broadcastHttpPort,
publicLink = publicLink,
onDismiss = { showSnapcastSheet = false },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.TunnelState> = tunnelManager.state

val settings: StateFlow<AppSettings> = settingsRepo.settings
.onEach { repo.setServerUrl(it.apiServer) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AppSettings())
Expand Down
4 changes: 3 additions & 1 deletion gradle/pins.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand Down
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}
}
Loading