From 7988211e9b70704e4f5cac27f2e6b9904f19f7ba Mon Sep 17 00:00:00 2001 From: Giovanni Salinas Date: Mon, 15 Jun 2026 09:13:28 +0800 Subject: [PATCH 1/3] feat: lib-shairport-android integration --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 + .../capullo/radio/airplay/AirplayProcess.kt | 61 +++++++++++++++++++ .../capullo/radio/data/ConfFileDataSource.kt | 41 +++++++++++++ .../radio/data/RadioAdvertisingDataSource.kt | 11 ++++ .../capullo/radio/data/RadioRepository.kt | 7 +++ .../radio/services/RadioBroadcasterService.kt | 28 +++++++++ .../radio/snapcast/SnapserverProcess.kt | 38 +++++++++--- gradle/libs.versions.toml | 3 + settings.gradle.kts | 4 ++ 10 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eef5e69..4650001 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,6 +102,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.lib.snapcast.android) + implementation(libs.lib.shairport.android) implementation("com.github.capullo-tech.lib-librespot-android:librespot-android:0.2.0") implementation(libs.slf4j.handroid) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fe864db..c5c6b91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,8 @@ + + diff --git a/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt b/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt new file mode 100644 index 0000000..d958f6f --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt @@ -0,0 +1,61 @@ +package tech.capullo.radio.airplay + +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import tech.capullo.radio.data.RadioRepository +import java.io.BufferedReader +import java.io.InputStreamReader +import javax.inject.Inject + +/** + * Runs shairport-sync (packaged by lib-shairport-android as an exec-able + * libshairport.so) as a classic AirPlay receiver writing 44100:16:2 PCM to + * the AirPlay FIFO, where snapserver picks it up as a stream source. + */ +class AirplayProcess @Inject constructor(private val radioRepository: RadioRepository) { + + private val nativeLibDir = radioRepository.getNativeLibDirPath() + + suspend fun start() = coroutineScope { + val pipeFilepath = radioRepository.getAirplayPipeFilepath() + if (pipeFilepath == null) { + Log.e(TAG, "Could not create the AirPlay PIPE, not starting shairport-sync") + return@coroutineScope + } + val confFile = radioRepository.getShairportConfPath(pipeFilepath) + + val pb = ProcessBuilder() + .command( + "$nativeLibDir/libshairport.so", + "-c", + confFile, + ) + .redirectErrorStream(true) + // Android hides MAC addresses from apps; shairport-sync's Android port + // derives its AirPlay device ID from this variable instead. + pb.environment()["SPS_DEVICE_ID"] = radioRepository.getAirplayDeviceId() + + val process = pb.start() + try { + val bufferedReader = BufferedReader( + InputStreamReader(process.inputStream), + ) + var line: String? + while (bufferedReader.readLine().also { line = it } != null) { + ensureActive() + Log.d(TAG, "shairport-sync: ${line!!}") + } + } catch (_: CancellationException) { + process.destroy() + process.waitFor() + } catch (e: Exception) { + Log.e(TAG, "Error running the shairport-sync process", e) + } + } + + companion object { + private val TAG = AirplayProcess::class.java.simpleName + } +} diff --git a/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt b/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt index 6f227cc..2219103 100644 --- a/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt +++ b/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt @@ -28,6 +28,46 @@ class ConfFileDataSource @Inject constructor(@ApplicationContext private val app } } + // Idempotent, unlike getPipeFilepath(): both snapserver (reader) and + // shairport-sync (writer) resolve this path independently, so recreating + // the FIFO here could leave them attached to different inodes. + fun getAirplayPipeFilepath(): String? { + val pipeFile = File(getCacheDir(), AIRPLAY_PIPE_NAME) + + if (pipeFile.exists()) { + return pipeFile.absolutePath + } + + Log.d(TAG, "Creating AirPlay PIPE: ${pipeFile.absolutePath}") + try { + mkfifo(pipeFile.absolutePath, S_IRUSR or S_IWUSR) + return pipeFile.absolutePath + } catch (e: Exception) { + Log.e(TAG, "Error creating AirPlay PIPE file: ${e.message}") + return null + } + } + + // shairport-sync settings are given via its libconfig-style conf file, + // rewritten on each start since the device name can change + fun getShairportConfPath(deviceName: String, pipeFilepath: String): String { + val confFile = File(getCacheDir(), "shairport-sync.conf") + val escapedName = deviceName.replace("\\", "\\\\").replace("\"", "\\\"") + confFile.writeText( + """ + general = { + name = "$escapedName"; + output_backend = "pipe"; + }; + pipe = { + name = "$pipeFilepath"; + }; + """.trimIndent(), + ) + Log.d(TAG, "Wrote shairport-sync.conf: ${confFile.absolutePath}") + return confFile.absolutePath + } + fun getNativeLibDirPath(): String = appContext.applicationInfo.nativeLibraryDir fun getCacheDir(): File = appContext.cacheDir @@ -54,5 +94,6 @@ class ConfFileDataSource @Inject constructor(@ApplicationContext private val app companion object { private val TAG = ConfFileDataSource::class.java.simpleName private const val PIPE_NAME = "filifo" + private const val AIRPLAY_PIPE_NAME = "airplay-fifo" } } diff --git a/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt b/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt index 7dd36ab..262d74b 100644 --- a/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt +++ b/app/src/main/java/tech/capullo/radio/data/RadioAdvertisingDataSource.kt @@ -21,6 +21,17 @@ class RadioAdvertisingDataSource @Inject constructor( } } + // Stable 12-hex-digit identifier used by shairport-sync as its AirPlay + // device ID (SPS_DEVICE_ID): Android hides interface MAC addresses from + // apps, so one is derived from ANDROID_ID instead. + @Suppress("HardwareIds") + fun getAirplayDeviceId(): String = Settings.Secure.getString( + appContext.contentResolver, + Settings.Secure.ANDROID_ID, + ).filter { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' } + .padEnd(12, '0') + .take(12) + fun getDeviceName(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { val deviceName = Settings.Global.getString( appContext.contentResolver, diff --git a/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt b/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt index b94e7ec..ef876c9 100644 --- a/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt +++ b/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt @@ -11,6 +11,13 @@ class RadioRepository @Inject constructor( ) { fun getPipeFilepath(): String? = confFileDataSource.getPipeFilepath() + fun getAirplayPipeFilepath(): String? = confFileDataSource.getAirplayPipeFilepath() + + fun getShairportConfPath(pipeFilepath: String): String = + confFileDataSource.getShairportConfPath(getDeviceName(), pipeFilepath) + + fun getAirplayDeviceId(): String = radioAdvertisingDataSource.getAirplayDeviceId() + fun getNativeLibDirPath(): String = confFileDataSource.getNativeLibDirPath() fun getCacheDir(): File = confFileDataSource.getCacheDir() diff --git a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt index 0ee9c65..1ef150c 100644 --- a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt +++ b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt @@ -30,6 +30,7 @@ import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.annotations.Range +import tech.capullo.radio.airplay.AirplayProcess import tech.capullo.radio.espoti.AudioFocusManager import tech.capullo.radio.espoti.EspotiPlayerManager import tech.capullo.radio.espoti.EspotiSessionRepository @@ -64,6 +65,8 @@ class RadioBroadcasterService : Service() { @Inject lateinit var snapserverNsdManager: SnapserverNsdManager + @Inject lateinit var airplayProcess: AirplayProcess + private val playbackExecutor: ExecutorService = Executors.newSingleThreadExecutor() private var player: Player? = null private var session: Session? = null @@ -74,8 +77,13 @@ class RadioBroadcasterService : Service() { private var snapserverJob: Job? = null private var snapclientJob: Job? = null + private var airplayJob: Job? = null private var controlBridge: SnapcastControlBridge? = null + // shairport-sync's in-process mDNS responder (tinysvcmdns) only receives + // queries while multicast is allowed through the Wi-Fi filter + private var multicastLock: android.net.wifi.WifiManager.MulticastLock? = null + private var isPlaying: Boolean = false private var isMuted: Boolean = false private var currentVolumePercent: Int = 100 @@ -158,6 +166,7 @@ class RadioBroadcasterService : Service() { super.onTaskRemoved(rootIntent) controlBridge?.stop() snapserverNsdManager.stop() + releaseMulticastLock() scope.cancel() player?.close() session?.close() @@ -170,6 +179,7 @@ class RadioBroadcasterService : Service() { super.onDestroy() controlBridge?.stop() snapserverNsdManager.stop() + releaseMulticastLock() scope.cancel() player?.close() session?.close() @@ -408,11 +418,29 @@ class RadioBroadcasterService : Service() { start() } } + acquireMulticastLock() snapserverJob = scope.launch { snapserverProcess.start() } snapclientJob = scope.launch { snapclientProcess.start() } + airplayJob = scope.launch { airplayProcess.start() } scope.launch { snapserverNsdManager.start() } } + private fun acquireMulticastLock() { + if (multicastLock == null) { + val wifiManager = + applicationContext.getSystemService(WIFI_SERVICE) as android.net.wifi.WifiManager + multicastLock = wifiManager.createMulticastLock("RadioCapulloAirplayMdns").apply { + setReferenceCounted(false) + acquire() + } + } + } + + private fun releaseMulticastLock() { + multicastLock?.takeIf { it.isHeld }?.release() + multicastLock = null + } + private fun buildStreamProperties(): StreamProperties { val playerInstance = espotiPlayerManager.playerNullable() val durationMs = currentMetadata?.duration() ?: 0 diff --git a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt index cbc40af..2ddbf86 100644 --- a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt +++ b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt @@ -16,32 +16,54 @@ class SnapserverProcess @Inject constructor(radioRepository: RadioRepository) { private val cacheDir = radioRepository.getCacheDir() private val confFile = radioRepository.getSnapserverConfPath() private val pipeFilepath = radioRepository.getPipeFilepath()!! + private val airplayPipeFilepath = radioRepository.getAirplayPipeFilepath() companion object { - private const val STREAM_NAME: String = "name=RadioCapullo" private const val PIPE_MODE: String = "mode=read" private const val DRYOUT_MS: String = "dryout_ms=2000" private const val SAMPLE_FORMAT: String = "sampleformat=44100:16:2" + // codec=null hides the raw inputs from clients, so the meta stream + // below is the default stream that new client groups attach to + // (StreamManager::getDefaultStream skips null-codec streams). private val pipeArgs = listOf( - STREAM_NAME, PIPE_MODE, DRYOUT_MS, SAMPLE_FORMAT, + "codec=null", ).joinToString("&") private val TAG = SnapserverProcess::class.java.simpleName } suspend fun start() = coroutineScope { + val streamSources = mutableListOf( + "--stream.source", + "pipe://$pipeFilepath?name=Spotify&$pipeArgs" + + "&controlscript=$nativeLibDir/libsnapcontrol.so", + ) + if (airplayPipeFilepath != null) { + streamSources += listOf( + "--stream.source", + "pipe://$airplayPipeFilepath?name=Airplay&$pipeArgs", + ) + } + // The meta stream activates whichever input is playing (earlier names + // win on conflict) and must be declared after the streams it combines. + val metaPath = if (airplayPipeFilepath != null) "Spotify/Airplay" else "Spotify" + streamSources += listOf( + "--stream.source", + "meta:///$metaPath?name=RadioCapullo&$SAMPLE_FORMAT", + ) + val pb = ProcessBuilder() .command( - "$nativeLibDir/libsnapserver.so", - "--config", - confFile, - "--server.datadir=$cacheDir", - "--stream.source", - "pipe://$pipeFilepath?$pipeArgs&controlscript=$nativeLibDir/libsnapcontrol.so", + listOf( + "$nativeLibDir/libsnapserver.so", + "--config", + confFile, + "--server.datadir=$cacheDir", + ) + streamSources, ) .redirectErrorStream(true) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aef9fa6..baa60a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ coreKtx = "1.18.0" hilt = "2.59.2" ksp = "2.3.7" ktor = "3.4.3" +libShairportAndroid = "0.1.0" libSnapcastAndroid = "lr_snapcast-SNAPSHOT" lifecycleRuntimeKtx = "2.10.0" lifecycleViewmodelNav3 = "2.10.0" @@ -68,6 +69,8 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } ktor-network = { group = "io.ktor", name = "ktor-network", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +# from mavenLocal until published; switch group to com.github.capullo-tech.lib-shairport-android for JitPack +lib-shairport-android = { group = "tech.capullo", name = "lib-shairport-android", version.ref = "libShairportAndroid" } lib-snapcast-android = { group = "com.github.capullo-tech.lib-snapcast-android", name = "lib-snapcast-android", version.ref = "libSnapcastAndroid" } mockk = { group= "io.mockk", name = "mockk", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } diff --git a/settings.gradle.kts b/settings.gradle.kts index f59ad67..098f025 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,10 @@ dependencyResolutionManagement { google() mavenCentral() maven { url = uri("https://www.jitpack.io") } + // lib-shairport-android is consumed from the local maven repo until it + // is published; build it with: + // lib-shairport-android$ ./gradlew publishToMavenLocal + mavenLocal() } } From aa57175424338f10d7592eee52249b818276f7e4 Mon Sep 17 00:00:00 2001 From: Giovanni Salinas Date: Thu, 18 Jun 2026 09:03:04 +0800 Subject: [PATCH 2/3] wip: airplay spotify split --- .../java/tech/capullo/radio/RadioNavHost.kt | 2 +- .../capullo/radio/airplay/AirplayProcess.kt | 8 +- .../radio/services/RadioBroadcasterService.kt | 116 ++++++++++++++++-- .../capullo/radio/snapcast/ProcessStatus.kt | 14 +++ .../radio/snapcast/SnapclientProcess.kt | 10 +- .../radio/snapcast/SnapserverProcess.kt | 10 +- .../capullo/radio/ui/BroadcasterScreen.kt | 50 ++++++++ .../radio/ui/EspotiSessionLoadingScreen.kt | 24 +++- .../radio/viewmodels/BroadcasterViewModel.kt | 24 +++- .../EspotiSessionLoadingViewModel.kt | 21 +--- .../capullo/radio/SessionRepositoryTest.kt | 13 -- 11 files changed, 227 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/tech/capullo/radio/snapcast/ProcessStatus.kt diff --git a/app/src/main/java/tech/capullo/radio/RadioNavHost.kt b/app/src/main/java/tech/capullo/radio/RadioNavHost.kt index 5b0ded4..3834fc9 100644 --- a/app/src/main/java/tech/capullo/radio/RadioNavHost.kt +++ b/app/src/main/java/tech/capullo/radio/RadioNavHost.kt @@ -57,7 +57,7 @@ fun RadioCapulloNavHost() { schemeChoice = SchemeChoice.GREEN, ) { EspotiSessionLoadingScreen( - onPlayerReady = { + onProceedToBroadcast = { backStack.add(Broadcast) }, ) diff --git a/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt b/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt index d958f6f..d64d216 100644 --- a/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt +++ b/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt @@ -1,7 +1,6 @@ package tech.capullo.radio.airplay import android.util.Log -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import tech.capullo.radio.data.RadioRepository @@ -38,6 +37,9 @@ class AirplayProcess @Inject constructor(private val radioRepository: RadioRepos pb.environment()["SPS_DEVICE_ID"] = radioRepository.getAirplayDeviceId() val process = pb.start() + // Cleanup lives in finally so cancellation propagates to the + // supervisor (which distinguishes a clean stop from a crash); a normal + // native exit returns and lets the supervisor restart. try { val bufferedReader = BufferedReader( InputStreamReader(process.inputStream), @@ -47,11 +49,9 @@ class AirplayProcess @Inject constructor(private val radioRepository: RadioRepos ensureActive() Log.d(TAG, "shairport-sync: ${line!!}") } - } catch (_: CancellationException) { + } finally { process.destroy() process.waitFor() - } catch (e: Exception) { - Log.e(TAG, "Error running the shairport-sync process", e) } } diff --git a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt index 1ef150c..cbc0c5b 100644 --- a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt +++ b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt @@ -9,17 +9,21 @@ import android.content.pm.ServiceInfo import android.os.Binder import android.os.Build import android.os.IBinder +import android.os.SystemClock import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext @@ -32,9 +36,11 @@ import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.annotations.Range import tech.capullo.radio.airplay.AirplayProcess import tech.capullo.radio.espoti.AudioFocusManager +import tech.capullo.radio.espoti.EspotiNsdManager import tech.capullo.radio.espoti.EspotiPlayerManager import tech.capullo.radio.espoti.EspotiSessionRepository import tech.capullo.radio.snapcast.ArtData +import tech.capullo.radio.snapcast.ProcessStatus import tech.capullo.radio.snapcast.SnapcastControlBridge import tech.capullo.radio.snapcast.SnapclientProcess import tech.capullo.radio.snapcast.SnapserverNsdManager @@ -67,6 +73,12 @@ class RadioBroadcasterService : Service() { @Inject lateinit var airplayProcess: AirplayProcess + // Spotify Connect zeroconf discovery + login handler. Lives in the service + // (not the loading screen's ViewModel) so a user who skips Spotify can still + // connect later while broadcasting; a successful login emits + // SessionState.Created and brings up the Spotify stack. + @Inject lateinit var espotiNsdManager: EspotiNsdManager + private val playbackExecutor: ExecutorService = Executors.newSingleThreadExecutor() private var player: Player? = null private var session: Session? = null @@ -80,6 +92,19 @@ class RadioBroadcasterService : Service() { private var airplayJob: Job? = null private var controlBridge: SnapcastControlBridge? = null + // The receiver stack (snapserver/snapclient/AirPlay/NSD) is independent of + // Spotify and starts with the service; the Spotify stack (player) starts + // only once a session exists. Flags keep both idempotent across repeated + // onStartCommand calls and SessionState.Created emissions. + private var receiverStarted = false + private var spotifyStarted = false + + // Health of the supervised native processes, surfaced to the broadcaster UI + private val _airplayStatus = MutableStateFlow(ProcessStatus.STARTING) + val airplayStatus = _airplayStatus.asStateFlow() + private val _snapserverStatus = MutableStateFlow(ProcessStatus.STARTING) + val snapserverStatus = _snapserverStatus.asStateFlow() + // shairport-sync's in-process mDNS responder (tinysvcmdns) only receives // queries while multicast is allowed through the Wi-Fi filter private var multicastLock: android.net.wifi.WifiManager.MulticastLock? = null @@ -96,6 +121,8 @@ class RadioBroadcasterService : Service() { private val binder = LocalBinder() inner class LocalBinder : Binder() { fun getIsPlayerLoadingFlow() = this@RadioBroadcasterService.isPlayerLoadingFlow + fun getAirplayStatusFlow() = this@RadioBroadcasterService.airplayStatus + fun getSnapserverStatusFlow() = this@RadioBroadcasterService.snapserverStatus fun updateAudioChannel(channel: AudioChannel) = this@RadioBroadcasterService.updateAudioChannel(channel) } @@ -146,6 +173,9 @@ class RadioBroadcasterService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startForeground() + // The receiver stack is independent of Spotify and comes up as soon as + // the service does. Guarded so repeated starts don't relaunch it. + startReceiverStack() return START_NOT_STICKY } @@ -394,12 +424,9 @@ class RadioBroadcasterService : Service() { when (sessionState) { is EspotiSessionRepository.SessionState.Created -> { session = sessionState.session - // TODO: might need to handle player session thrown errors - espotiPlayerManager.createPlayer() - startLibrespot() - - // Start snapcast processes after we have a valid session - startSnapcast() + // Only the Spotify-specific pieces wait on a session; + // the receiver stack is already running from onStartCommand. + startSpotifyStack() } is EspotiSessionRepository.SessionState.Error -> { @@ -412,17 +439,75 @@ class RadioBroadcasterService : Service() { } } - fun startSnapcast() { + fun startReceiverStack() { + if (receiverStarted) return + receiverStarted = true + + // Bind the control-bridge socket before snapserver starts: snapserver + // spawns libsnapcontrol.so for the Spotify stream regardless of login, + // and it connects back to this socket. The bridge is null-safe without + // a player (see buildStreamProperties / bridgePlayerCallbacks). if (controlBridge == null) { controlBridge = SnapcastControlBridge(player = bridgePlayerCallbacks).apply { start() } } acquireMulticastLock() - snapserverJob = scope.launch { snapserverProcess.start() } - snapclientJob = scope.launch { snapclientProcess.start() } - airplayJob = scope.launch { airplayProcess.start() } + snapserverJob = launchSupervised("snapserver", _snapserverStatus) { + snapserverProcess.start() + } + snapclientJob = launchSupervised("snapclient") { + snapclientProcess.start(audioChannel = currentAudioChannel.ordinal) + } + airplayJob = launchSupervised("airplay", _airplayStatus) { + airplayProcess.start() + } scope.launch { snapserverNsdManager.start() } + // Advertise Spotify Connect + handle logins for the life of the service, + // so the device is connectable whether or not the user logs in up front. + scope.launch { espotiNsdManager.start() } + } + + fun startSpotifyStack() { + if (spotifyStarted) return + spotifyStarted = true + + espotiPlayerManager.createPlayer() + startLibrespot() + } + + /** + * Launches [block] (a native-process run loop) and restarts it with + * exponential backoff if it exits unexpectedly, so a crash or network blip + * recovers instead of silently killing the source. Cancellation (service + * teardown, channel change) propagates out and stops the loop cleanly. + */ + private fun launchSupervised( + name: String, + status: MutableStateFlow? = null, + block: suspend () -> Unit, + ): Job = scope.launch { + var backoffMs = INITIAL_BACKOFF_MS + while (isActive) { + val startedAt = SystemClock.elapsedRealtime() + status?.value = ProcessStatus.RUNNING + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "$name failed", e) + } + if (!isActive) break + if (SystemClock.elapsedRealtime() - startedAt > HEALTHY_RUN_MS) { + backoffMs = INITIAL_BACKOFF_MS + } + status?.value = ProcessStatus.RESTARTING + Log.w(TAG, "$name exited; restarting in ${backoffMs}ms") + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + } + status?.value = ProcessStatus.STOPPED } private fun acquireMulticastLock() { @@ -604,8 +689,9 @@ class RadioBroadcasterService : Service() { currentAudioChannel = channel // Restart snapclient with new channel snapclientJob?.cancel() - snapclientJob = - scope.launch { snapclientProcess.start(audioChannel = currentAudioChannel.ordinal) } + snapclientJob = launchSupervised("snapclient") { + snapclientProcess.start(audioChannel = currentAudioChannel.ordinal) + } } companion object { @@ -613,6 +699,12 @@ class RadioBroadcasterService : Service() { const val CHANNEL_NAME = "Radio Broadcaster Service Channel" const val NOTIFICATION_ID = 100 const val WAIT_READY_TIMEOUT_MS = 10_000L + + // Restart-supervisor backoff bounds for the native receiver processes + const val INITIAL_BACKOFF_MS = 1_000L + const val MAX_BACKOFF_MS = 30_000L + const val HEALTHY_RUN_MS = 10_000L + val TAG: String = RadioBroadcasterService::class.java.simpleName } } diff --git a/app/src/main/java/tech/capullo/radio/snapcast/ProcessStatus.kt b/app/src/main/java/tech/capullo/radio/snapcast/ProcessStatus.kt new file mode 100644 index 0000000..2c58a1a --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/snapcast/ProcessStatus.kt @@ -0,0 +1,14 @@ +package tech.capullo.radio.snapcast + +/** + * Health of a supervised native process (snapserver / shairport-sync) as + * tracked by [tech.capullo.radio.services.RadioBroadcasterService]'s restart + * supervisor. Surfaced to the broadcaster UI so a crashed or recovering + * source is visible instead of log-only. + */ +enum class ProcessStatus { + STARTING, + RUNNING, + RESTARTING, + STOPPED, +} diff --git a/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt b/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt index 796febd..29b27a9 100644 --- a/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt +++ b/app/src/main/java/tech/capullo/radio/snapcast/SnapclientProcess.kt @@ -8,7 +8,6 @@ import android.os.Process import android.util.Log import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive @@ -100,6 +99,9 @@ class SnapclientProcess @Inject constructor( if (fpb != null) env["FRAMES_PER_BUFFER"] = fpb val process = pb.start() + // Cleanup lives in finally so cancellation propagates to the + // supervisor (which distinguishes a clean stop from a crash); a normal + // native exit returns and lets the supervisor restart. try { val bufferedReader = BufferedReader( InputStreamReader(process.inputStream), @@ -147,13 +149,9 @@ class SnapclientProcess @Inject constructor( val threadName = Thread.currentThread().name // Log.d(TAG, "Running on: $processId - $threadName - ${line!!}") } - } catch (_: CancellationException) { - // Log.d(TAG, "Snapclient process cancelled") + } finally { process.destroy() process.waitFor() - // Log.d(TAG, "Snapclient process destroyed") - } catch (e: Exception) { - // Log.e(TAG, "Error starting snapcast process", e) } } diff --git a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt index 2ddbf86..10e32f7 100644 --- a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt +++ b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt @@ -2,7 +2,6 @@ package tech.capullo.radio.snapcast import android.os.Process import android.util.Log -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import tech.capullo.radio.data.RadioRepository @@ -68,6 +67,9 @@ class SnapserverProcess @Inject constructor(radioRepository: RadioRepository) { .redirectErrorStream(true) val process = pb.start() + // Cleanup lives in finally so cancellation propagates to the + // supervisor (which distinguishes a clean stop from a crash); a normal + // native exit returns and lets the supervisor restart. try { val bufferedReader = BufferedReader( InputStreamReader(process.inputStream), @@ -79,13 +81,9 @@ class SnapserverProcess @Inject constructor(radioRepository: RadioRepository) { val threadName = Thread.currentThread().name // Log.d(TAG, "Running on: $processId - $threadName - ${line!!}") } - } catch (_: CancellationException) { - // Log.d(TAG, "Snapserver process cancelled") + } finally { process.destroy() process.waitFor() - // Log.d(TAG, "Snapserver process destroyed") - } catch (e: Exception) { - // Log.e(TAG, "Error starting snapcast process", e) } } } diff --git a/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt index a2fcd7d..56e455f 100644 --- a/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/BroadcasterScreen.kt @@ -57,6 +57,7 @@ import tech.capullo.radio.snapcast.ClientConfig import tech.capullo.radio.snapcast.Group import tech.capullo.radio.snapcast.Host import tech.capullo.radio.snapcast.LastSeen +import tech.capullo.radio.snapcast.ProcessStatus import tech.capullo.radio.snapcast.SnapClient import tech.capullo.radio.snapcast.SnapcastControlClient import tech.capullo.radio.snapcast.StreamMetadata @@ -158,6 +159,11 @@ fun BroadcasterScreenContent( ) { IPv4AddressesCard(uiState.ipv4AddressesResult, onRefreshHostAddresses) + ReceiverStatusBanner( + airplay = uiState.airplayStatus, + snapserver = uiState.snapserverStatus, + ) + if (controlConnected) { SnapserverGroups( groups = groups, @@ -249,6 +255,50 @@ fun BroadcasterScreenContent( } } +/** + * Surfaces native receiver health (AirPlay / snapserver). Self-hides while both + * are healthy so the broadcaster screen stays clean; shows a warning card when a + * source is restarting or has stopped. + */ +@Composable +private fun ReceiverStatusBanner(airplay: ProcessStatus, snapserver: ProcessStatus) { + val issues = buildList { + when (snapserver) { + ProcessStatus.RESTARTING -> add("Broadcast server is restarting…") + ProcessStatus.STOPPED -> add("Broadcast server stopped") + else -> {} + } + when (airplay) { + ProcessStatus.RESTARTING -> add("AirPlay receiver is restarting…") + ProcessStatus.STOPPED -> add("AirPlay receiver stopped") + else -> {} + } + } + if (issues.isEmpty()) return + + Card( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + issues.forEach { message -> + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + @Composable private fun ConnectionStatusChip(state: SnapcastControlClient.ConnectionState) { Surface( diff --git a/app/src/main/java/tech/capullo/radio/ui/EspotiSessionLoadingScreen.kt b/app/src/main/java/tech/capullo/radio/ui/EspotiSessionLoadingScreen.kt index 4061481..7aeccb3 100644 --- a/app/src/main/java/tech/capullo/radio/ui/EspotiSessionLoadingScreen.kt +++ b/app/src/main/java/tech/capullo/radio/ui/EspotiSessionLoadingScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -36,7 +37,7 @@ import tech.capullo.radio.viewmodels.EspotiSessionLoadingViewModel @Composable fun EspotiSessionLoadingScreen( viewModel: EspotiSessionLoadingViewModel = hiltViewModel(), - onPlayerReady: () -> Unit, + onProceedToBroadcast: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -45,20 +46,27 @@ fun EspotiSessionLoadingScreen( LaunchedEffect(uiState.isPlayerReady) { if (uiState.isPlayerReady) { - onPlayerReady() + onProceedToBroadcast() } } - EspotiSessionLoadingScreenContent(uiState) + EspotiSessionLoadingScreenContent( + uiState = uiState, + onBroadcastWithoutSpotify = onProceedToBroadcast, + ) } @Composable -fun EspotiSessionLoadingScreenContent(uiState: EspotiSessionLoadingUiState) { +fun EspotiSessionLoadingScreenContent( + uiState: EspotiSessionLoadingUiState, + onBroadcastWithoutSpotify: () -> Unit = {}, +) { if (uiState.isLoading) { LoadingStoredSessionScreen() } else { EspotiConnectScreen( deviceName = uiState.deviceName, + onBroadcastWithoutSpotify = onBroadcastWithoutSpotify, ) } } @@ -92,7 +100,7 @@ fun LoadingStoredSessionScreen() { @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun EspotiConnectScreen(deviceName: String) { +fun EspotiConnectScreen(deviceName: String, onBroadcastWithoutSpotify: () -> Unit = {}) { Scaffold { innerPadding -> Column( modifier = Modifier @@ -123,6 +131,12 @@ fun EspotiConnectScreen(deviceName: String) { } Spacer(modifier = Modifier.padding(8.dp)) LinearWavyProgressIndicator() + Spacer(modifier = Modifier.padding(16.dp)) + // AirPlay / Snapcast don't need Spotify — let the user start + // broadcasting now and connect Spotify later if they want. + TextButton(onClick = onBroadcastWithoutSpotify) { + Text(text = "Broadcast without Spotify") + } } } } diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt index 181671b..a33fda7 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/BroadcasterViewModel.kt @@ -31,6 +31,7 @@ import tech.capullo.radio.snapcast.ClientOnDisconnect import tech.capullo.radio.snapcast.ClientOnLatencyChanged import tech.capullo.radio.snapcast.ClientOnVolumeChanged import tech.capullo.radio.snapcast.Group +import tech.capullo.radio.snapcast.ProcessStatus import tech.capullo.radio.snapcast.ServerGetStatusResponse import tech.capullo.radio.snapcast.ServerOnUpdate import tech.capullo.radio.snapcast.SnapcastControlClient @@ -53,6 +54,8 @@ data class BroadcasterUiState( val canGoNext: Boolean = false, val canGoPrevious: Boolean = false, val artistDisplay: String? = null, + val airplayStatus: ProcessStatus = ProcessStatus.STARTING, + val snapserverStatus: ProcessStatus = ProcessStatus.STARTING, ) @HiltViewModel @@ -76,6 +79,14 @@ class BroadcasterViewModel @Inject constructor( MutableStateFlow(IPv4AddressesResult.Loading) private val audioChannelFlow = MutableStateFlow(AudioChannel.STEREO) + // Native receiver health, piped from the bound service on connect. + private val airplayStatusFlow = MutableStateFlow(ProcessStatus.STARTING) + private val snapserverStatusFlow = MutableStateFlow(ProcessStatus.STARTING) + private val receiverStatusFlow: Flow> = combine( + airplayStatusFlow, + snapserverStatusFlow, + ) { airplay, snapserver -> airplay to snapserver } + private val activeStreamFlow: Flow = snapshotFlow { // TODO: single-stream assumption — multi-stream needs group→stream mapping. streams.firstOrNull() @@ -86,7 +97,9 @@ class BroadcasterViewModel @Inject constructor( audioChannelFlow, snapcastControlClient.connectionState, activeStreamFlow, - ) { ipv4, channel, controlConnState, activeStream -> + receiverStatusFlow, + ) { ipv4, channel, controlConnState, activeStream, receiverStatus -> + val (airplayStatus, snapserverStatus) = receiverStatus val artistDisplay = when (val a = activeStream?.properties?.metadata?.artist) { is JsonPrimitive -> a.content is JsonArray -> a.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ") @@ -104,6 +117,8 @@ class BroadcasterViewModel @Inject constructor( canGoNext = activeStream?.properties?.canGoNext ?: false, canGoPrevious = activeStream?.properties?.canGoPrevious ?: false, artistDisplay = artistDisplay, + airplayStatus = airplayStatus, + snapserverStatus = snapserverStatus, ) }.stateIn( scope = viewModelScope, @@ -123,6 +138,13 @@ class BroadcasterViewModel @Inject constructor( mBound = true mService = binder + viewModelScope.launch { + binder.getAirplayStatusFlow().collect { airplayStatusFlow.value = it } + } + viewModelScope.launch { + binder.getSnapserverStatusFlow().collect { snapserverStatusFlow.value = it } + } + startSnapcastControlClient() } diff --git a/app/src/main/java/tech/capullo/radio/viewmodels/EspotiSessionLoadingViewModel.kt b/app/src/main/java/tech/capullo/radio/viewmodels/EspotiSessionLoadingViewModel.kt index 3451d12..c38adb5 100644 --- a/app/src/main/java/tech/capullo/radio/viewmodels/EspotiSessionLoadingViewModel.kt +++ b/app/src/main/java/tech/capullo/radio/viewmodels/EspotiSessionLoadingViewModel.kt @@ -11,15 +11,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import tech.capullo.radio.di.IODispatcher -import tech.capullo.radio.espoti.EspotiNsdManager import tech.capullo.radio.espoti.EspotiSessionRepository import tech.capullo.radio.services.RadioBroadcasterService import javax.inject.Inject @@ -34,8 +30,6 @@ data class EspotiSessionLoadingUiState( class EspotiSessionLoadingViewModel @Inject constructor( @ApplicationContext val appContext: Context, private val espotiSessionRepository: EspotiSessionRepository, - private val espotiNsdManager: EspotiNsdManager, - @IODispatcher private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { // Starts in the loading state @@ -65,22 +59,15 @@ class EspotiSessionLoadingViewModel @Inject constructor( // serviceWrapper?.isPlayerLoading?.isLoading -> responds -> UI updates espotiSessionRepository.setSession(storedSession.session) } else { - // Finished loading and didn't transition to the next screen - // therefore start nsd routine + // No stored session: show the Spotify Connect screen. Discovery + + // login are advertised by RadioBroadcasterService for the life of + // the service, so a connection can arrive here or after the user + // proceeds to the broadcast screen without Spotify. _uiState.update { it.copy(isLoading = false) } - startEspotiNsd() } } } - fun startEspotiNsd() { - println("start EspotiNsd on: ${Process.myPid()} - ${Thread.currentThread().name}}") - viewModelScope.launch(ioDispatcher) { - println("espotiNsdManager on: ${Process.myPid()} - ${Thread.currentThread().name}}") - espotiNsdManager.start() - } - } - private var mBound: Boolean = false private var mService: RadioBroadcasterService.LocalBinder? = null diff --git a/app/src/test/java/tech/capullo/radio/SessionRepositoryTest.kt b/app/src/test/java/tech/capullo/radio/SessionRepositoryTest.kt index 85ba799..0b52736 100644 --- a/app/src/test/java/tech/capullo/radio/SessionRepositoryTest.kt +++ b/app/src/test/java/tech/capullo/radio/SessionRepositoryTest.kt @@ -1,7 +1,6 @@ package tech.capullo.radio import android.content.Context -import android.net.nsd.NsdManager import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -16,10 +15,7 @@ import org.junit.runner.RunWith import tech.capullo.radio.data.ConfFileDataSource import tech.capullo.radio.data.RadioAdvertisingDataSource import tech.capullo.radio.data.RadioRepository -import tech.capullo.radio.espoti.EspotiConnectHandler -import tech.capullo.radio.espoti.EspotiNsdManager import tech.capullo.radio.espoti.EspotiSessionRepository -import tech.capullo.radio.espoti.EspotiZeroconfServer import tech.capullo.radio.viewmodels.EspotiSessionLoadingViewModel @RunWith(AndroidJUnit4::class) @@ -47,18 +43,9 @@ class SessionRepositoryTest { radioRepository, ) - val nsdManager = appContext.getSystemService(Context.NSD_SERVICE) as NsdManager - val espotiConnectHandler = EspotiConnectHandler(espotiSessionRepository) - val espotiZeroconfServer = EspotiZeroconfServer(espotiConnectHandler) - val espotiNsdManager = EspotiNsdManager( - nsdManager, - espotiZeroconfServer, - ) val viewModel = EspotiSessionLoadingViewModel( appContext, espotiSessionRepository, - espotiNsdManager = espotiNsdManager, - ioDispatcher = dispatcher, ) assert(viewModel.uiState.value.isLoading) From 7004a77fb9c9d4752c6cfbfb433936a06048f215 Mon Sep 17 00:00:00 2001 From: Giovanni Salinas Date: Tue, 23 Jun 2026 17:48:30 +0800 Subject: [PATCH 3/3] wip: airplay snapcontrol support --- .../radio/airplay/AirplayMetadataParser.kt | 216 ++++++++++++++++++ .../radio/airplay/AirplayMetadataReader.kt | 67 ++++++ .../capullo/radio/airplay/AirplayProcess.kt | 5 +- .../capullo/radio/airplay/DacpController.kt | 212 +++++++++++++++++ .../capullo/radio/airplay/DacpCredentials.kt | 12 + .../capullo/radio/data/ConfFileDataSource.kt | 47 +++- .../capullo/radio/data/RadioRepository.kt | 7 +- .../radio/services/RadioBroadcasterService.kt | 68 ++++++ .../radio/snapcast/SnapserverProcess.kt | 12 +- .../radio/AirplayMetadataParserTest.kt | 151 ++++++++++++ gradle/libs.versions.toml | 9 +- settings.gradle.kts | 4 - 12 files changed, 794 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataParser.kt create mode 100644 app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataReader.kt create mode 100644 app/src/main/java/tech/capullo/radio/airplay/DacpController.kt create mode 100644 app/src/main/java/tech/capullo/radio/airplay/DacpCredentials.kt create mode 100644 app/src/test/java/tech/capullo/radio/AirplayMetadataParserTest.kt diff --git a/app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataParser.kt b/app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataParser.kt new file mode 100644 index 0000000..7b38113 --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataParser.kt @@ -0,0 +1,216 @@ +package tech.capullo.radio.airplay + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import tech.capullo.radio.snapcast.ArtData +import tech.capullo.radio.snapcast.StreamMetadata +import tech.capullo.radio.snapcast.StreamProperties + +/** + * Parses shairport-sync's metadata pipe stream (emitted when built with + * CONFIG_METADATA) into Snapcast [StreamProperties]. Pure / no Android deps so + * it can be unit-tested; base64 decoding is injected. + * + * shairport writes newline-terminated items (see rtsp.c `metadata_process`): + * + * HEXHEXN + * + * BASE64 + * + * where the `` block is absent for zero-length items, the base64 has no + * internal newlines, and HEX is the big-endian uint32 of a 4-char code. Codes + * arrive under two types: `core` (DAAP metadata) and `ssnc` (shairport-sync + * control). Feed raw pipe text in arbitrary chunks; complete items are parsed + * and a cumulative snapshot is returned. + */ +class AirplayMetadataParser(private val decodeBase64: (String) -> ByteArray) { + + private val buffer = StringBuilder() + + // Cumulative player state, updated as items arrive. + private var title: String? = null + private var artist: String? = null + private var album: String? = null + private var durationSec: Float? = null + private var positionSec: Float = 0f + private var artDataBase64: String? = null + private var artExtension: String = "jpg" + private var volumePercent: Int? = null + private var muted: Boolean = false + + // null = nothing has played yet (reported as "stopped"). + private var playing: Boolean? = null + + // DACP remote-control credentials from the connected sender (ssnc acre/daid). + private var activeRemote: String? = null + private var dacpId: String? = null + + /** DACP credentials, non-null only once both acre and daid have arrived. */ + fun credentials(): DacpCredentials? { + val ar = activeRemote + val id = dacpId + return if (ar != null && id != null) DacpCredentials(id, ar) else null + } + + /** + * Feeds a chunk of raw pipe text. Returns a cumulative [StreamProperties] + * snapshot if at least one complete item was parsed, else null. + */ + fun feed(text: String): StreamProperties? { + buffer.append(text) + var lastEnd = 0 + var changed = false + for (match in ITEM_REGEX.findAll(buffer)) { + processItem(match) + lastEnd = match.range.last + 1 + changed = true + } + if (lastEnd > 0) buffer.delete(0, lastEnd) + return if (changed) snapshot() else null + } + + /** Clears any partial item left over (e.g. after the writer closed). */ + fun resetBuffer() = buffer.setLength(0) + + fun snapshot(): StreamProperties { + val controllable = credentials() != null + val hasMetadata = title != null || artist != null || album != null || + artDataBase64 != null || durationSec != null + val metadata = if (hasMetadata) { + StreamMetadata( + album = album, + artist = artist?.let { JsonArray(listOf(JsonPrimitive(it))) }, + track = null, + title = title, + duration = durationSec, + artUrl = null, + artData = artDataBase64?.let { ArtData(data = it, extension = artExtension) }, + ) + } else { + null + } + return StreamProperties( + playbackStatus = when (playing) { + true -> "playing" + false -> "paused" + null -> "stopped" + }, + loopStatus = "none", + shuffle = false, + volume = volumePercent, + mute = muted, + rate = 1.0f, + position = positionSec, + // Controls are advertised only once we hold DACP credentials, i.e. + // the sender is reachable for transport commands. Seek is unsupported. + canControl = controllable, + canGoNext = controllable, + canGoPrevious = controllable, + canPause = controllable, + canPlay = controllable, + canSeek = false, + metadata = metadata, + ) + } + + private fun processItem(match: MatchResult) { + val type = fourCc(match.groupValues[1]) + val code = fourCc(match.groupValues[2]) + val data = match.groups[4]?.value + + when (type) { + "core" -> when (code) { + "minm" -> title = data?.let { decodeText(it) } + "asar" -> artist = data?.let { decodeText(it) } + "asal" -> album = data?.let { decodeText(it) } + "astm" -> data?.let { durationSec = decodeBigEndianInt(it) / 1000f } + } + + "ssnc" -> when (code) { + "PICT" -> if (data != null && data.isNotBlank()) { + artDataBase64 = data + artExtension = detectImageExtension(data) + } + + // Begin / resume -> playing; flush (pause) / end (stop) -> not. + "pbeg", "prsm" -> playing = true + + "pfls", "pend" -> playing = false + + "prgr" -> data?.let { parseProgress(decodeText(it)) } + + "pvol" -> data?.let { parseVolume(decodeText(it)) } + + // DACP remote-control credentials for driving the sender. + "acre" -> activeRemote = data?.let { decodeText(it) } + + "daid" -> dacpId = data?.let { decodeText(it) } + } + } + } + + private fun decodeText(base64: String): String = + String(decodeBase64(base64), Charsets.UTF_8).trim() + + private fun decodeBigEndianInt(base64: String): Int { + val bytes = decodeBase64(base64) + var value = 0 + for (b in bytes) value = (value shl 8) or (b.toInt() and 0xFF) + return value + } + + // "start/current/end" in RTP frames (44100 Hz). Yields position & duration. + private fun parseProgress(text: String) { + val parts = text.split('/') + if (parts.size != 3) return + val start = parts[0].trim().toLongOrNull() ?: return + val current = parts[1].trim().toLongOrNull() ?: return + val end = parts[2].trim().toLongOrNull() ?: return + positionSec = ((current - start).coerceAtLeast(0)) / RTP_RATE + if (end > start) durationSec = (end - start) / RTP_RATE + } + + // "airplay_volume,volume,lowest,highest"; airplay_volume is -30..0 dB, or + // <= -144 for mute. Mapped linearly to 0..100 for display only. + private fun parseVolume(text: String) { + val airplayVolume = text.split(',').firstOrNull()?.trim()?.toFloatOrNull() ?: return + if (airplayVolume <= MUTE_THRESHOLD_DB) { + muted = true + } else { + muted = false + volumePercent = (((airplayVolume + VOLUME_RANGE_DB) / VOLUME_RANGE_DB) * 100f) + .toInt().coerceIn(0, 100) + } + } + + // Detect from the base64 prefix of the image's magic bytes: + // PNG 0x89 'P' 'N' 'G' -> "iVBOR"; JPEG 0xFF 0xD8 0xFF -> "/9j/". + private fun detectImageExtension(base64: String): String = when { + base64.startsWith("iVBOR") -> "png" + base64.startsWith("/9j/") -> "jpg" + else -> "jpg" + } + + private fun fourCc(hex: String): String { + val v = hex.toLong(16) + return charArrayOf( + ((v shr 24) and 0xFF).toInt().toChar(), + ((v shr 16) and 0xFF).toInt().toChar(), + ((v shr 8) and 0xFF).toInt().toChar(), + (v and 0xFF).toInt().toChar(), + ).concatToString() + } + + companion object { + private const val RTP_RATE = 44100f + private const val MUTE_THRESHOLD_DB = -144f + private const val VOLUME_RANGE_DB = 30f + + private val ITEM_REGEX = Regex( + "([0-9a-fA-F]+)([0-9a-fA-F]+)" + + "(\\d+)" + + "(?:\\n\\n(.*?))?", + RegexOption.DOT_MATCHES_ALL, + ) + } +} diff --git a/app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataReader.kt b/app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataReader.kt new file mode 100644 index 0000000..ae37d29 --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/airplay/AirplayMetadataReader.kt @@ -0,0 +1,67 @@ +package tech.capullo.radio.airplay + +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import tech.capullo.radio.data.RadioRepository +import tech.capullo.radio.snapcast.StreamProperties +import java.io.FileInputStream +import java.io.InputStreamReader +import javax.inject.Inject + +/** + * Reads shairport-sync's metadata FIFO and exposes the parsed AirPlay + * [StreamProperties] (title/artist/album/cover-art + play state) as a + * [StateFlow] for the Airplay Snapcast control bridge to push to clients. + * + * Like [AirplayProcess]/SnapserverProcess this is a supervised run loop: opening + * the FIFO O_RDONLY blocks until shairport-sync opens the write end (same + * rendezvous as the audio pipe), and a returned [run] (writer closed -> EOF) + * lets the service's supervisor reopen it for the next session. + */ +class AirplayMetadataReader @Inject constructor(private val radioRepository: RadioRepository) { + private val parser = AirplayMetadataParser { Base64.decode(it, Base64.DEFAULT) } + + private val _properties = MutableStateFlow(parser.snapshot()) + val properties: StateFlow = _properties.asStateFlow() + + private val _credentials = MutableStateFlow(parser.credentials()) + val credentials: StateFlow = _credentials.asStateFlow() + + suspend fun run() = coroutineScope { + val pipePath = radioRepository.getAirplayMetadataPipeFilepath() + if (pipePath == null) { + Log.e(TAG, "No AirPlay metadata PIPE; not reading shairport metadata") + return@coroutineScope + } + + // Drop any partial item buffered from a previous (closed) session. + parser.resetBuffer() + + // Blocks until shairport-sync opens the write end. + FileInputStream(pipePath).use { fis -> + val reader = InputStreamReader(fis, Charsets.ISO_8859_1) + val buf = CharArray(BUFFER_SIZE) + var read: Int + while (reader.read(buf).also { read = it } != -1) { + currentCoroutineContext().ensureActive() + if (!isActive) break + parser.feed(String(buf, 0, read))?.let { + _properties.value = it + _credentials.value = parser.credentials() + } + } + } + } + + companion object { + private val TAG = AirplayMetadataReader::class.java.simpleName + private const val BUFFER_SIZE = 4096 + } +} diff --git a/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt b/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt index d64d216..1ce6e14 100644 --- a/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt +++ b/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt @@ -23,7 +23,10 @@ class AirplayProcess @Inject constructor(private val radioRepository: RadioRepos Log.e(TAG, "Could not create the AirPlay PIPE, not starting shairport-sync") return@coroutineScope } - val confFile = radioRepository.getShairportConfPath(pipeFilepath) + // Idempotent like the audio FIFO; AirplayMetadataReader opens the same + // path for reading. Null (mkfifo failure) just omits the metadata block. + val metadataPipeFilepath = radioRepository.getAirplayMetadataPipeFilepath() + val confFile = radioRepository.getShairportConfPath(pipeFilepath, metadataPipeFilepath) val pb = ProcessBuilder() .command( diff --git a/app/src/main/java/tech/capullo/radio/airplay/DacpController.kt b/app/src/main/java/tech/capullo/radio/airplay/DacpController.kt new file mode 100644 index 0000000..dea28f4 --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/airplay/DacpController.kt @@ -0,0 +1,212 @@ +package tech.capullo.radio.airplay + +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.os.ext.SdkExtensions +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject + +/** + * Sends DACP remote-control commands to the connected AirPlay sender so Snapcast + * clients can drive transport/volume on the iPhone/Mac/iTunes. + * + * The sender advertises a `_dacp._tcp` service named `iTunes_Ctrl_`; we + * resolve it to host:port via [NsdManager] (mirrors [tech.capullo.radio.snapcast + * .SnapserverDiscoveryManager]) and issue `GET /ctrl-int/1/` with the + * `Active-Remote` header (shairport's own protocol — see dacp.c). Credentials + * come from [AirplayMetadataReader] over shairport's metadata pipe. + */ +class DacpController @Inject constructor(private val nsdManager: NsdManager) { + + private data class DacpServer(val host: String, val port: Int) + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Volatile private var server: DacpServer? = null + + @Volatile private var activeRemote: String? = null + private var targetDacpId: String? = null + private var lastVolumePercent: Int = 100 + + private var discoveryListener: NsdManager.DiscoveryListener? = null + private val resolveListeners = mutableMapOf() + + /** + * React to credential changes from the metadata pipe: (re)start `_dacp._tcp` + * discovery for a new DACP-ID, refresh the Active-Remote token, or tear down + * when the sender disconnects. + */ + @Synchronized + fun updateCredentials(credentials: DacpCredentials?) { + if (credentials == null) { + stopDiscovery() + server = null + activeRemote = null + targetDacpId = null + return + } + activeRemote = credentials.activeRemote + if (credentials.dacpId != targetDacpId) { + targetDacpId = credentials.dacpId + server = null + stopDiscovery() + startDiscovery() + } + } + + @Synchronized + fun stop() { + stopDiscovery() + server = null + activeRemote = null + targetDacpId = null + scope.cancel() + } + + // Transport / volume — mapped to DACP commands (dacp.c send_simple_dacp_command). + fun play() = send("play") + fun pause() = send("pause") + fun playPause() = send("playpause") + fun next() = send("nextitem") + fun previous() = send("previtem") + + fun setVolume(percent: Int) { + lastVolumePercent = percent.coerceIn(0, 100) + send("setproperty?dmcp.volume=$lastVolumePercent") + } + + fun setMute(muted: Boolean) { + send("setproperty?dmcp.volume=${if (muted) 0 else lastVolumePercent}") + } + + private fun send(command: String) { + scope.launch { sendCommand(command) } + } + + private suspend fun sendCommand(command: String) { + val target = server + val token = activeRemote + if (target == null || token == null) { + Log.d(TAG, "Dropping DACP '$command' — sender not resolved yet") + return + } + withContext(Dispatchers.IO) { + val host = if (target.host.contains(':')) "[${target.host}]" else target.host + val conn = URL("http://$host:${target.port}/ctrl-int/1/$command") + .openConnection() as HttpURLConnection + try { + conn.requestMethod = "GET" + conn.connectTimeout = TIMEOUT_MS + conn.readTimeout = TIMEOUT_MS + conn.setRequestProperty("Active-Remote", token) + val code = conn.responseCode + if (code !in 200..299) Log.w(TAG, "DACP '$command' -> HTTP $code") + } catch (e: Exception) { + Log.e(TAG, "DACP '$command' failed", e) + } finally { + conn.disconnect() + } + } + } + + private fun startDiscovery() { + val listener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) {} + + override fun onServiceFound(service: NsdServiceInfo) { + if (matchesTarget(service.serviceName)) resolveService(service) + } + + override fun onServiceLost(service: NsdServiceInfo) { + if (matchesTarget(service.serviceName)) server = null + } + + override fun onDiscoveryStopped(serviceType: String) {} + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "DACP discovery start failed: $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "DACP discovery stop failed: $errorCode") + } + } + discoveryListener = listener + nsdManager.discoverServices(DACP_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) + } + + private fun stopDiscovery() { + discoveryListener?.let { + try { + nsdManager.stopServiceDiscovery(it) + } catch (e: Exception) { + Log.e(TAG, "Error stopping DACP discovery", e) + } + } + discoveryListener = null + resolveListeners.values.forEach { stopResolution(it) } + resolveListeners.clear() + } + + private fun resolveService(serviceInfo: NsdServiceInfo) { + if (resolveListeners.containsKey(serviceInfo.serviceName)) return + val listener = object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "DACP resolve failed for ${info.serviceName}: $errorCode") + resolveListeners.remove(info.serviceName) + } + + override fun onServiceResolved(info: NsdServiceInfo) { + val host = info.host?.hostAddress + if (host != null) { + server = DacpServer(host, info.port) + Log.i(TAG, "DACP sender resolved: $host:${info.port}") + } + resolveListeners.remove(info.serviceName) + } + } + resolveListeners[serviceInfo.serviceName] = listener + nsdManager.resolveService(serviceInfo, listener) + } + + private fun stopResolution(listener: NsdManager.ResolveListener) { + // stopServiceResolution needs API 34 / T-extensions 7 (as in + // SnapserverDiscoveryManager); on older devices the resolve just lapses. + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7 + ) { + nsdManager.stopServiceResolution(listener) + } + } catch (e: Exception) { + Log.e(TAG, "Error stopping DACP resolution", e) + } + } + + // The sender advertises `iTunes_Ctrl_`; match the suffix against the + // target DACP-ID, leading-zero/case-insensitive (mirrors mdns_avahi.c). + private fun matchesTarget(serviceName: String): Boolean { + val id = targetDacpId ?: return false + if (!serviceName.startsWith(DACP_NAME_PREFIX)) return false + val found = serviceName.removePrefix(DACP_NAME_PREFIX) + return normalizeId(found).equals(normalizeId(id), ignoreCase = true) + } + + private fun normalizeId(id: String): String = id.trimStart('0').ifEmpty { "0" } + + companion object { + private val TAG = DacpController::class.java.simpleName + private const val DACP_SERVICE_TYPE = "_dacp._tcp" + private const val DACP_NAME_PREFIX = "iTunes_Ctrl_" + private const val TIMEOUT_MS = 5000 + } +} diff --git a/app/src/main/java/tech/capullo/radio/airplay/DacpCredentials.kt b/app/src/main/java/tech/capullo/radio/airplay/DacpCredentials.kt new file mode 100644 index 0000000..e7d2141 --- /dev/null +++ b/app/src/main/java/tech/capullo/radio/airplay/DacpCredentials.kt @@ -0,0 +1,12 @@ +package tech.capullo.radio.airplay + +/** + * DACP remote-control credentials for the connected AirPlay sender, delivered + * over shairport-sync's metadata pipe ([AirplayMetadataParser]): + * + * - [dacpId] (`ssnc/daid`) identifies the sender's `_dacp._tcp` control service, + * advertised as `iTunes_Ctrl_`, to be resolved to host:port. + * - [activeRemote] (`ssnc/acre`) is the token sent in the `Active-Remote` header + * of each DACP request. + */ +data class DacpCredentials(val dacpId: String, val activeRemote: String) diff --git a/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt b/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt index 2219103..7c27dbc 100644 --- a/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt +++ b/app/src/main/java/tech/capullo/radio/data/ConfFileDataSource.kt @@ -48,11 +48,51 @@ class ConfFileDataSource @Inject constructor(@ApplicationContext private val app } } + // Metadata FIFO shairport-sync writes its stream to (title/artist/ + // album/cover art + play state). Idempotent for the same inode reason as + // getAirplayPipeFilepath(): shairport-sync (writer) and AirplayMetadataReader + // (reader) open this path independently. + fun getAirplayMetadataPipeFilepath(): String? { + val pipeFile = File(getCacheDir(), AIRPLAY_METADATA_PIPE_NAME) + + if (pipeFile.exists()) { + return pipeFile.absolutePath + } + + Log.d(TAG, "Creating AirPlay metadata PIPE: ${pipeFile.absolutePath}") + try { + mkfifo(pipeFile.absolutePath, S_IRUSR or S_IWUSR) + return pipeFile.absolutePath + } catch (e: Exception) { + Log.e(TAG, "Error creating AirPlay metadata PIPE file: ${e.message}") + return null + } + } + // shairport-sync settings are given via its libconfig-style conf file, - // rewritten on each start since the device name can change - fun getShairportConfPath(deviceName: String, pipeFilepath: String): String { + // rewritten on each start since the device name can change. When + // metadataPipeFilepath is non-null, shairport-sync (built with + // CONFIG_METADATA) emits its metadata stream to that FIFO for + // AirplayMetadataReader to consume. + fun getShairportConfPath( + deviceName: String, + pipeFilepath: String, + metadataPipeFilepath: String? = null, + ): String { val confFile = File(getCacheDir(), "shairport-sync.conf") val escapedName = deviceName.replace("\\", "\\\\").replace("\"", "\\\"") + val metadataBlock = if (metadataPipeFilepath != null) { + """ + + metadata = { + enabled = "yes"; + include_cover_art = "yes"; + pipe_name = "$metadataPipeFilepath"; + }; + """.trimIndent() + } else { + "" + } confFile.writeText( """ general = { @@ -62,7 +102,7 @@ class ConfFileDataSource @Inject constructor(@ApplicationContext private val app pipe = { name = "$pipeFilepath"; }; - """.trimIndent(), + """.trimIndent() + metadataBlock, ) Log.d(TAG, "Wrote shairport-sync.conf: ${confFile.absolutePath}") return confFile.absolutePath @@ -95,5 +135,6 @@ class ConfFileDataSource @Inject constructor(@ApplicationContext private val app private val TAG = ConfFileDataSource::class.java.simpleName private const val PIPE_NAME = "filifo" private const val AIRPLAY_PIPE_NAME = "airplay-fifo" + private const val AIRPLAY_METADATA_PIPE_NAME = "airplay-metadata-fifo" } } diff --git a/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt b/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt index ef876c9..c5996e8 100644 --- a/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt +++ b/app/src/main/java/tech/capullo/radio/data/RadioRepository.kt @@ -13,8 +13,11 @@ class RadioRepository @Inject constructor( fun getAirplayPipeFilepath(): String? = confFileDataSource.getAirplayPipeFilepath() - fun getShairportConfPath(pipeFilepath: String): String = - confFileDataSource.getShairportConfPath(getDeviceName(), pipeFilepath) + fun getAirplayMetadataPipeFilepath(): String? = + confFileDataSource.getAirplayMetadataPipeFilepath() + + fun getShairportConfPath(pipeFilepath: String, metadataPipeFilepath: String? = null): String = + confFileDataSource.getShairportConfPath(getDeviceName(), pipeFilepath, metadataPipeFilepath) fun getAirplayDeviceId(): String = radioAdvertisingDataSource.getAirplayDeviceId() diff --git a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt index cbc0c5b..8f597db 100644 --- a/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt +++ b/app/src/main/java/tech/capullo/radio/services/RadioBroadcasterService.kt @@ -34,7 +34,9 @@ import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.annotations.Range +import tech.capullo.radio.airplay.AirplayMetadataReader import tech.capullo.radio.airplay.AirplayProcess +import tech.capullo.radio.airplay.DacpController import tech.capullo.radio.espoti.AudioFocusManager import tech.capullo.radio.espoti.EspotiNsdManager import tech.capullo.radio.espoti.EspotiPlayerManager @@ -73,6 +75,10 @@ class RadioBroadcasterService : Service() { @Inject lateinit var airplayProcess: AirplayProcess + @Inject lateinit var airplayMetadataReader: AirplayMetadataReader + + @Inject lateinit var dacpController: DacpController + // Spotify Connect zeroconf discovery + login handler. Lives in the service // (not the loading screen's ViewModel) so a user who skips Spotify can still // connect later while broadcasting; a successful login emits @@ -90,7 +96,9 @@ class RadioBroadcasterService : Service() { private var snapserverJob: Job? = null private var snapclientJob: Job? = null private var airplayJob: Job? = null + private var airplayMetadataJob: Job? = null private var controlBridge: SnapcastControlBridge? = null + private var airplayControlBridge: SnapcastControlBridge? = null // The receiver stack (snapserver/snapclient/AirPlay/NSD) is independent of // Spotify and starts with the service; the Spotify stack (player) starts @@ -195,6 +203,8 @@ class RadioBroadcasterService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) controlBridge?.stop() + airplayControlBridge?.stop() + dacpController.stop() snapserverNsdManager.stop() releaseMulticastLock() scope.cancel() @@ -208,6 +218,8 @@ class RadioBroadcasterService : Service() { override fun onDestroy() { super.onDestroy() controlBridge?.stop() + airplayControlBridge?.stop() + dacpController.stop() snapserverNsdManager.stop() releaseMulticastLock() scope.cancel() @@ -452,6 +464,16 @@ class RadioBroadcasterService : Service() { start() } } + // Same contract for the Airplay stream, on its own socket so its + // libsnapcontrol.so relay doesn't collide with the Spotify one. Phase 1 + // is read-only: metadata flows up from shairport-sync; transport + // callbacks are no-ops (see airplayBridgeCallbacks). + if (airplayControlBridge == null) { + airplayControlBridge = SnapcastControlBridge( + socketName = SnapserverProcess.AIRPLAY_CONTROL_SOCKET, + player = airplayBridgeCallbacks, + ).apply { start() } + } acquireMulticastLock() snapserverJob = launchSupervised("snapserver", _snapserverStatus) { snapserverProcess.start() @@ -462,6 +484,23 @@ class RadioBroadcasterService : Service() { airplayJob = launchSupervised("airplay", _airplayStatus) { airplayProcess.start() } + // Read shairport-sync's metadata pipe (reopened on each AirPlay session) + // and push every change to the Airplay control bridge. + airplayMetadataJob = launchSupervised("airplay-metadata") { + airplayMetadataReader.run() + } + scope.launch { + airplayMetadataReader.properties.collect { props -> + airplayControlBridge?.pushProperties(props) + } + } + // Drive DACP discovery from the sender's credentials (acre/daid) so the + // Airplay transport callbacks below can reach the sender. + scope.launch { + airplayMetadataReader.credentials.collect { creds -> + dacpController.updateCredentials(creds) + } + } scope.launch { snapserverNsdManager.start() } // Advertise Spotify Connect + handle logins for the life of the service, // so the device is connectable whether or not the user logs in up front. @@ -685,6 +724,35 @@ class RadioBroadcasterService : Service() { override fun currentProperties(): StreamProperties = buildStreamProperties() } + // AirPlay transport is driven back to the sender over DACP (DacpController). + // Seek/setPosition stay unsupported (canSeek=false); volume/mute map to the + // sender's dmcp.volume. + private val airplayBridgeCallbacks = object : SnapcastControlBridge.PlayerCallbacks { + override fun onPlay() = dacpController.play() + override fun onPause() = dacpController.pause() + override fun onPlayPause() = dacpController.playPause() + override fun onNext() = dacpController.next() + override fun onPrevious() = dacpController.previous() + + override fun onSeek(offsetUs: Long) { + Log.d(TAG, "AirPlay seek unsupported") + } + + override fun onSetPosition(trackId: String, posUs: Long) { + Log.d(TAG, "AirPlay setPosition unsupported") + } + + override fun onSetProperty(name: String, value: kotlinx.serialization.json.JsonElement) { + when (name) { + "volume" -> dacpController.setVolume(value.jsonPrimitive.double.toInt()) + "mute" -> dacpController.setMute(value.jsonPrimitive.booleanOrNull ?: false) + else -> Log.d(TAG, "AirPlay setProperty '$name' unsupported") + } + } + + override fun currentProperties(): StreamProperties = airplayMetadataReader.properties.value + } + fun updateAudioChannel(channel: AudioChannel) { currentAudioChannel = channel // Restart snapclient with new channel diff --git a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt index 10e32f7..ab4bd6a 100644 --- a/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt +++ b/app/src/main/java/tech/capullo/radio/snapcast/SnapserverProcess.kt @@ -18,6 +18,11 @@ class SnapserverProcess @Inject constructor(radioRepository: RadioRepository) { private val airplayPipeFilepath = radioRepository.getAirplayPipeFilepath() companion object { + // Abstract-socket name the Airplay stream's libsnapcontrol.so relay + // connects to; RadioBroadcasterService binds its Airplay control bridge + // here. Spotify uses the SnapcastControlBridge default ("snapcontrol"). + const val AIRPLAY_CONTROL_SOCKET: String = "snapcontrol-airplay" + private const val PIPE_MODE: String = "mode=read" private const val DRYOUT_MS: String = "dryout_ms=2000" private const val SAMPLE_FORMAT: String = "sampleformat=44100:16:2" @@ -42,9 +47,14 @@ class SnapserverProcess @Inject constructor(radioRepository: RadioRepository) { "&controlscript=$nativeLibDir/libsnapcontrol.so", ) if (airplayPipeFilepath != null) { + // Airplay gets its own control socket so its libsnapcontrol.so relay + // doesn't clobber the Spotify bridge (default "snapcontrol" socket); + // the bridge tracks a single active connection per socket name. streamSources += listOf( "--stream.source", - "pipe://$airplayPipeFilepath?name=Airplay&$pipeArgs", + "pipe://$airplayPipeFilepath?name=Airplay&$pipeArgs" + + "&controlscript=$nativeLibDir/libsnapcontrol.so" + + "&controlscriptparams=--socket-name=$AIRPLAY_CONTROL_SOCKET", ) } // The meta stream activates whichever input is playing (earlier names diff --git a/app/src/test/java/tech/capullo/radio/AirplayMetadataParserTest.kt b/app/src/test/java/tech/capullo/radio/AirplayMetadataParserTest.kt new file mode 100644 index 0000000..ae6906d --- /dev/null +++ b/app/src/test/java/tech/capullo/radio/AirplayMetadataParserTest.kt @@ -0,0 +1,151 @@ +package tech.capullo.radio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import tech.capullo.radio.airplay.AirplayMetadataParser +import java.util.Base64 + +class AirplayMetadataParserTest { + + private fun parser() = AirplayMetadataParser { Base64.getDecoder().decode(it) } + + // Builds one shairport-sync metadata item exactly as rtsp.c metadata_process + // emits it (see the format note in AirplayMetadataParser). + private fun item(type: String, code: String, value: ByteArray?): String { + val typeHex = fourCcHex(type) + val codeHex = fourCcHex(code) + return if (value == null) { + "$typeHex$codeHex0\n" + } else { + val b64 = Base64.getEncoder().encodeToString(value) + "$typeHex$codeHex${value.size}\n" + + "\n$b64\n" + } + } + + private fun textItem(type: String, code: String, value: String) = + item(type, code, value.toByteArray(Charsets.UTF_8)) + + private fun fourCcHex(s: String): String { + var v = 0L + for (c in s) v = (v shl 8) or c.code.toLong() + return v.toString(16) + } + + @Test + fun parsesTitleArtistAlbumAndPlayState() { + val p = parser() + p.feed(textItem("core", "minm", "My Song")) + p.feed(textItem("core", "asar", "My Artist")) + p.feed(textItem("core", "asal", "My Album")) + val props = p.feed(item("ssnc", "pbeg", null)) + + assertNotNull(props) + assertEquals("playing", props!!.playbackStatus) + assertEquals("My Song", props.metadata?.title) + assertEquals("My Album", props.metadata?.album) + assertTrue(props.metadata?.artist.toString().contains("My Artist")) + // Phase 1 read-only: controls advertised off. + assertEquals(false, props.canControl) + assertEquals(false, props.canGoNext) + } + + @Test + fun pendMarksPaused() { + val p = parser() + p.feed(item("ssnc", "pbeg", null)) + val props = p.feed(item("ssnc", "pend", null)) + assertEquals("paused", props!!.playbackStatus) + } + + @Test + fun stoppedBeforeAnyPlayState() { + val props = parser().feed(textItem("core", "minm", "Song")) + assertEquals("stopped", props!!.playbackStatus) + } + + @Test + fun decodesDurationFromAstmMillis() { + val durationMs = 215_000 + val bytes = byteArrayOf( + (durationMs shr 24 and 0xFF).toByte(), + (durationMs shr 16 and 0xFF).toByte(), + (durationMs shr 8 and 0xFF).toByte(), + (durationMs and 0xFF).toByte(), + ) + val props = parser().feed(item("core", "astm", bytes)) + assertEquals(215.0f, props!!.metadata?.duration!!, 0.001f) + } + + @Test + fun parsesProgressIntoPositionAndDuration() { + // start=44100, current=132300, end=8864100 (RTP frames @ 44100 Hz) + val props = parser().feed(textItem("ssnc", "prgr", "44100/132300/8864100")) + assertEquals(2.0f, props!!.position!!, 0.001f) + assertEquals(200.0f, props.metadata?.duration ?: 0f, 0.5f) + } + + @Test + fun detectsJpegCoverArtExtension() { + val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0x00) + val props = parser().feed(item("ssnc", "PICT", jpeg)) + assertEquals("jpg", props!!.metadata?.artData?.extension) + assertNotNull(props.metadata?.artData?.data) + } + + @Test + fun detectsPngCoverArtExtension() { + val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) + val props = parser().feed(item("ssnc", "PICT", png)) + assertEquals("png", props!!.metadata?.artData?.extension) + } + + @Test + fun handlesItemSplitAcrossFeeds() { + val full = textItem("core", "minm", "Split Title") + val cut = full.length / 2 + val p = parser() + assertNull(p.feed(full.substring(0, cut))) + val props = p.feed(full.substring(cut)) + assertEquals("Split Title", props!!.metadata?.title) + } + + @Test + fun mapsVolumeAndMute() { + val p = parser() + // airplay_volume 0 dB -> 100% + var props = p.feed(textItem("ssnc", "pvol", "0.0,0.0,-30.0,0.0")) + assertEquals(100, props!!.volume) + assertEquals(false, props.mute) + // -144 -> muted + props = p.feed(textItem("ssnc", "pvol", "-144.0,-144.0,-30.0,0.0")) + assertEquals(true, props!!.mute) + } + + @Test + fun controlEnabledOnceDacpCredentialsPresent() { + val p = parser() + // Read-only until DACP credentials arrive. + var props = p.feed(item("ssnc", "pbeg", null)) + assertEquals(false, props!!.canControl) + + p.feed(textItem("ssnc", "acre", "3105466202")) + props = p.feed(textItem("ssnc", "daid", "DF6FDF152E3705EF")) + assertEquals(true, props!!.canControl) + assertEquals(true, props.canGoNext) + assertEquals(true, props.canGoPrevious) + assertEquals(true, props.canPause) + assertEquals(true, props.canPlay) + // Seek is never supported over DACP in this integration. + assertEquals(false, props.canSeek) + } + + @Test + fun controlStaysOffWithOnlyActiveRemote() { + val props = parser().feed(textItem("ssnc", "acre", "3105466202")) + assertEquals(false, props!!.canControl) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index baa60a5..ce6f127 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,8 +18,8 @@ coreKtx = "1.18.0" hilt = "2.59.2" ksp = "2.3.7" ktor = "3.4.3" -libShairportAndroid = "0.1.0" -libSnapcastAndroid = "lr_snapcast-SNAPSHOT" +libShairportAndroid = "c4e283ca4a" +libSnapcastAndroid = "16c4b638c3" lifecycleRuntimeKtx = "2.10.0" lifecycleViewmodelNav3 = "2.10.0" material3 = "1.5.0-alpha19" @@ -69,9 +69,8 @@ ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.r ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } ktor-network = { group = "io.ktor", name = "ktor-network", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } -# from mavenLocal until published; switch group to com.github.capullo-tech.lib-shairport-android for JitPack -lib-shairport-android = { group = "tech.capullo", name = "lib-shairport-android", version.ref = "libShairportAndroid" } -lib-snapcast-android = { group = "com.github.capullo-tech.lib-snapcast-android", name = "lib-snapcast-android", version.ref = "libSnapcastAndroid" } +lib-shairport-android = { group = "com.github.capullo-tech", name = "lib-shairport-android", version.ref = "libShairportAndroid" } +lib-snapcast-android = { group = "com.github.capullo-tech", name = "lib-snapcast-android", version.ref = "libSnapcastAndroid" } mockk = { group= "io.mockk", name = "mockk", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } slf4j-handroid = { module = "com.gitlab.mvysny.slf4j:slf4j-handroid", version.ref = "slf4jHandroid" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 098f025..f59ad67 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { google() mavenCentral() maven { url = uri("https://www.jitpack.io") } - // lib-shairport-android is consumed from the local maven repo until it - // is published; build it with: - // lib-shairport-android$ ./gradlew publishToMavenLocal - mavenLocal() } }