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/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/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`):
+ *
+ * - HEX
HEXN
+ *
+ * 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
new file mode 100644
index 0000000..1ce6e14
--- /dev/null
+++ b/app/src/main/java/tech/capullo/radio/airplay/AirplayProcess.kt
@@ -0,0 +1,64 @@
+package tech.capullo.radio.airplay
+
+import android.util.Log
+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
+ }
+ // 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(
+ "$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()
+ // 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),
+ )
+ var line: String?
+ while (bufferedReader.readLine().also { line = it } != null) {
+ ensureActive()
+ Log.d(TAG, "shairport-sync: ${line!!}")
+ }
+ } finally {
+ process.destroy()
+ process.waitFor()
+ }
+ }
+
+ companion object {
+ private val TAG = AirplayProcess::class.java.simpleName
+ }
+}
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 6f227cc..7c27dbc 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,86 @@ 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
+ }
+ }
+
+ // 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. 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 = {
+ name = "$escapedName";
+ output_backend = "pipe";
+ };
+ pipe = {
+ name = "$pipeFilepath";
+ };
+ """.trimIndent() + metadataBlock,
+ )
+ 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 +134,7 @@ 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"
+ private const val AIRPLAY_METADATA_PIPE_NAME = "airplay-metadata-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..c5996e8 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,16 @@ class RadioRepository @Inject constructor(
) {
fun getPipeFilepath(): String? = confFileDataSource.getPipeFilepath()
+ fun getAirplayPipeFilepath(): String? = confFileDataSource.getAirplayPipeFilepath()
+
+ fun getAirplayMetadataPipeFilepath(): String? =
+ confFileDataSource.getAirplayMetadataPipeFilepath()
+
+ fun getShairportConfPath(pipeFilepath: String, metadataPipeFilepath: String? = null): String =
+ confFileDataSource.getShairportConfPath(getDeviceName(), pipeFilepath, metadataPipeFilepath)
+
+ 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..8f597db 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
@@ -30,10 +34,15 @@ 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
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
@@ -64,6 +73,18 @@ class RadioBroadcasterService : Service() {
@Inject lateinit var snapserverNsdManager: SnapserverNsdManager
+ @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
+ // 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
@@ -74,7 +95,27 @@ 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
+ // 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
private var isPlaying: Boolean = false
private var isMuted: Boolean = false
@@ -88,6 +129,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)
}
@@ -138,6 +181,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
}
@@ -157,7 +203,10 @@ class RadioBroadcasterService : Service() {
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
controlBridge?.stop()
+ airplayControlBridge?.stop()
+ dacpController.stop()
snapserverNsdManager.stop()
+ releaseMulticastLock()
scope.cancel()
player?.close()
session?.close()
@@ -169,7 +218,10 @@ class RadioBroadcasterService : Service() {
override fun onDestroy() {
super.onDestroy()
controlBridge?.stop()
+ airplayControlBridge?.stop()
+ dacpController.stop()
snapserverNsdManager.stop()
+ releaseMulticastLock()
scope.cancel()
player?.close()
session?.close()
@@ -384,12 +436,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 -> {
@@ -402,15 +451,118 @@ 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()
}
}
- snapserverJob = scope.launch { snapserverProcess.start() }
- snapclientJob = scope.launch { snapclientProcess.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()
+ }
+ snapclientJob = launchSupervised("snapclient") {
+ snapclientProcess.start(audioChannel = currentAudioChannel.ordinal)
+ }
+ 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.
+ 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() {
+ 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 {
@@ -572,12 +724,42 @@ 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
snapclientJob?.cancel()
- snapclientJob =
- scope.launch { snapclientProcess.start(audioChannel = currentAudioChannel.ordinal) }
+ snapclientJob = launchSupervised("snapclient") {
+ snapclientProcess.start(audioChannel = currentAudioChannel.ordinal)
+ }
}
companion object {
@@ -585,6 +767,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 cbc40af..ab4bd6a 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
@@ -16,36 +15,71 @@ 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"
+ // 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"
+ // 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) {
+ // 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" +
+ "&controlscript=$nativeLibDir/libsnapcontrol.so" +
+ "&controlscriptparams=--socket-name=$AIRPLAY_CONTROL_SOCKET",
+ )
+ }
+ // 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)
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),
@@ -57,13 +91,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/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/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)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index aef9fa6..ce6f127 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -18,7 +18,8 @@ coreKtx = "1.18.0"
hilt = "2.59.2"
ksp = "2.3.7"
ktor = "3.4.3"
-libSnapcastAndroid = "lr_snapcast-SNAPSHOT"
+libShairportAndroid = "c4e283ca4a"
+libSnapcastAndroid = "16c4b638c3"
lifecycleRuntimeKtx = "2.10.0"
lifecycleViewmodelNav3 = "2.10.0"
material3 = "1.5.0-alpha19"
@@ -68,7 +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" }
-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" }