19 KiB
Status
✅ iOS implementation complete - See GrooveAudio (iOS)/ directory
✅ Android implementation complete - See GrooveAudio (Android)/ directory
Both apps are ready to build and run. See the respective directories for implementation details.
Groove Audio — Native Mobile Apps
Instructions for an AI coding agent to build two native apps — one for iOS (Swift/SwiftUI) and one for Android (Kotlin/Jetpack Compose) — that stream lossless audio from the Groove Audio server and display now-playing information.
What the server exposes
| Endpoint | Protocol | Purpose |
|---|---|---|
http://<HOST>:8000/stream.flac |
HTTP chunked (FLAC) | Lossless audio stream |
http://<HOST>:8180/api/mopidy/rpc |
HTTP JSON-RPC | Mopidy playback control |
ws://<HOST>:8180/ws/mopidy |
WebSocket | Real-time Mopidy events |
http://<HOST>:8180/api/snapcast/rpc |
HTTP JSON-RPC | Snapcast zone info |
Default LAN host: 192.168.178.100
Stream authentication
The FLAC stream requires HTTP Basic Auth:
- Username:
listener - Password: value of
ICECAST_LISTENER_PASSWORDin the server's.env(default:groove_listen)
The app must pass Authorization: Basic <base64(listener:password)> in every HTTP request to the stream URL.
Mopidy JSON-RPC quick reference
All calls are HTTP POST to /api/mopidy/rpc with Content-Type: application/json.
// Get current track
{"jsonrpc":"2.0","id":1,"method":"core.playback.get_current_track","params":{}}
// Get playback state (playing/paused/stopped)
{"jsonrpc":"2.0","id":2,"method":"core.playback.get_state","params":{}}
// Get cover art URLs for a list of URIs
{"jsonrpc":"2.0","id":3,"method":"core.library.get_images",
"params":{"uris":["<track_uri>"]}}
// Pause / resume / next / previous
{"jsonrpc":"2.0","id":4,"method":"core.playback.pause","params":{}}
{"jsonrpc":"2.0","id":5,"method":"core.playback.resume","params":{}}
{"jsonrpc":"2.0","id":6,"method":"core.playback.next","params":{}}
{"jsonrpc":"2.0","id":7,"method":"core.playback.previous","params":{}}
// Volume (0-100)
{"jsonrpc":"2.0","id":8,"method":"core.mixer.get_volume","params":{}}
{"jsonrpc":"2.0","id":9,"method":"core.mixer.set_volume","params":{"volume":80}}
Mopidy WebSocket events
Connect to ws://<HOST>:8180/ws/mopidy. The server pushes JSON event objects whenever playback changes. Listen for:
event: "track_playback_started"— new track; containstl_track.trackevent: "track_playback_paused"/"track_playback_resumed"event: "volume_changed"— containsvolume
App requirements (both platforms)
Core features
- Audio streaming — play the FLAC stream continuously in the background
- Now-playing screen — show track title, artist, album, and cover art
- Transport controls — play/pause, next, previous
- Volume slider — controls Mopidy software mixer volume (not device volume)
- Background audio — stream continues when app is backgrounded
- Lock screen / notification controls — show now-playing metadata and transport buttons
Nice to have
- Reconnect automatically when stream drops or server is unreachable
- Buffering indicator
- Settings screen to configure server host and listener password
iOS implementation guide
Project setup
- SwiftUI app, iOS 16+ deployment target
- Add
AVFoundationandCombineframeworks (no additional packages needed) - Enable Background Modes capability: check
Audio, AirPlay, and Picture in Picture - Set
NSAppTransportSecurity > NSAllowsLocalNetworkingtoYESin Info.plist (for LAN HTTP) - If connecting over the internet via plain HTTP, add the server hostname to
NSExceptionDomainsor use HTTPS (see internet access section)
Audio streaming — AVPlayer with authentication
import AVFoundation
class StreamPlayer: ObservableObject {
private var player: AVPlayer?
@Published var isPlaying = false
func play(host: String, password: String) {
let urlString = "http://\(host):8000/stream.flac"
guard let url = URL(string: urlString) else { return }
// AVAsset with custom HTTP headers for Basic Auth
let credentials = "listener:\(password)"
let encoded = Data(credentials.utf8).base64EncodedString()
let asset = AVURLAsset(url: url, options: [
"AVURLAssetHTTPHeaderFieldsKey": ["Authorization": "Basic \(encoded)"]
])
let item = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: item)
player?.play()
isPlaying = true
// Configure audio session for background playback
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try? AVAudioSession.sharedInstance().setActive(true)
}
func pause() { player?.pause(); isPlaying = false }
func resume() { player?.play(); isPlaying = true }
}
Note on FLAC streaming: AVPlayer supports FLAC on iOS 11+. For HTTP-streamed FLAC, set preferredForwardBufferDuration on the AVPlayerItem (e.g. 10 seconds) to ensure smooth playback. If AVPlayer stalls, implement an AVAssetResourceLoader delegate to handle HTTP + auth manually and feed raw bytes to the player — this gives full control over reconnection.
Now-playing info (Control Center / Lock Screen)
import MediaPlayer
func updateNowPlaying(title: String, artist: String, album: String, artwork: UIImage?) {
var info = [String: Any]()
info[MPMediaItemPropertyTitle] = title
info[MPMediaItemPropertyArtist] = artist
info[MPMediaItemPropertyAlbumTitle] = album
if let img = artwork {
info[MPMediaItemPropertyArtwork] =
MPMediaItemArtwork(boundsSize: img.size) { _ in img }
}
info[MPNowPlayingInfoPropertyIsLiveStream] = true
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
func setupRemoteControls(player: StreamPlayer, mopidy: MopidyClient) {
let cc = MPRemoteCommandCenter.shared()
cc.playCommand.addTarget { _ in player.resume(); return .success }
cc.pauseCommand.addTarget { _ in player.pause(); return .success }
cc.nextTrackCommand.addTarget { _ in Task { try? await mopidy.call("core.playback.next") }; return .success }
cc.previousTrackCommand.addTarget { _ in Task { try? await mopidy.call("core.playback.previous") }; return .success }
}
Mopidy JSON-RPC client
struct MopidyClient {
let baseURL: String // e.g. "http://192.168.178.100:8180"
private var nextID = 1
mutating func call<T: Decodable>(_ method: String, params: [String: Any] = [:]) async throws -> T {
let url = URL(string: "\(baseURL)/api/mopidy/rpc")!
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = ["jsonrpc": "2.0", "id": nextID, "method": method, "params": params]
req.httpBody = try JSONSerialization.data(withJSONObject: body)
nextID += 1
let (data, _) = try await URLSession.shared.data(for: req)
let wrapper = try JSONDecoder().decode(RPCResponse<T>.self, from: data)
return wrapper.result
}
}
WebSocket for real-time events
import Foundation
class MopidyEventStream: NSObject, ObservableObject, URLSessionWebSocketDelegate {
private var task: URLSessionWebSocketTask?
func connect(host: String) {
let url = URL(string: "ws://\(host):8180/ws/mopidy")!
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
task = session.webSocketTask(with: url)
task?.resume()
receive()
}
private func receive() {
task?.receive { [weak self] result in
if case .success(.string(let text)) = result,
let data = text.data(using: .utf8),
let event = try? JSONDecoder().decode(MopidyEvent.self, from: data) {
DispatchQueue.main.async { self?.handle(event) }
}
self?.receive()
}
}
private func handle(_ event: MopidyEvent) {
// Trigger a fresh core.playback.get_current_track call, update UI
}
}
Suggested iOS project structure
GrooveAudio (iOS)/
├── App/
│ ├── GrooveAudioApp.swift # @main, sets up AVAudioSession
│ └── AppSettings.swift # host + password (UserDefaults / Keychain)
├── Audio/
│ ├── StreamPlayer.swift # AVPlayer wrapper, auth, reconnect
│ └── NowPlayingController.swift # MPNowPlayingInfoCenter + MPRemoteCommandCenter
├── Mopidy/
│ ├── MopidyClient.swift # JSON-RPC HTTP client
│ ├── MopidyEventStream.swift # WebSocket event listener
│ └── Models.swift # Track, State, RPCResponse, MopidyEvent
├── UI/
│ ├── NowPlayingView.swift # Main screen: artwork, title, controls
│ ├── VolumeView.swift # Slider → core.mixer.set_volume
│ └── SettingsView.swift # Host, password, connection status
└── Assets/
└── placeholder_artwork.png
Android implementation guide
Project setup
- Android Studio project, Kotlin, Jetpack Compose UI
- Min SDK: 26 (Android 8), target SDK: 35
- Add to
app/build.gradle.kts:
dependencies {
// Media3 (ExoPlayer successor — recommended for new projects)
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-session:1.4.1") // MediaSession + notification
// HTTP + JSON
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Coil for cover art
implementation("io.coil-kt:coil-compose:2.7.0")
// Compose + lifecycle
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
}
- Add to
AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Foreground service for background audio -->
<service
android:name=".audio.GroovePlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
- If the server uses plain HTTP (not HTTPS), add a network security config that allows cleartext for the server's IP/hostname, or set
android:usesCleartextTraffic="true"on the<application>tag for development.
Audio streaming — Media3 ExoPlayer with authentication
ExoPlayer handles HTTP FLAC streaming natively. Inject the Authorization header via a custom DataSource.Factory:
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.common.MediaItem
import okhttp3.OkHttpClient
fun buildPlayer(context: Context, host: String, password: String): ExoPlayer {
val credentials = "listener:$password"
val encoded = android.util.Base64.encodeToString(
credentials.toByteArray(), android.util.Base64.NO_WRAP
)
val httpClient = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("Authorization", "Basic $encoded")
.build()
)
}
.build()
val dataSourceFactory = OkHttpDataSource.Factory(httpClient)
val trackSelector = DefaultTrackSelector(context)
val player = ExoPlayer.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(context).setDataSourceFactory(dataSourceFactory)
)
.build()
val mediaItem = MediaItem.fromUri("http://$host:8000/stream.flac")
player.setMediaItem(mediaItem)
player.prepare()
player.playWhenReady = true
return player
}
Background playback — MediaSessionService
Create a foreground service so audio continues when the app is backgrounded and the notification shows transport controls:
import androidx.media3.session.MediaSessionService
import androidx.media3.session.MediaSession
class GroovePlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
val prefs = getSharedPreferences("groove", MODE_PRIVATE)
val host = prefs.getString("host", "192.168.178.100")!!
val password = prefs.getString("password", "groove_listen")!!
val player = buildPlayer(this, host, password)
mediaSession = MediaSession.Builder(this, player)
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
mediaSession = null
}
super.onDestroy()
}
}
Media3's MediaSessionService automatically creates a persistent notification with play/pause/next/previous buttons and handles lock screen integration on Android 13+.
Now-playing metadata
Update MediaItem metadata so the notification and lock screen show the correct track info:
fun updateNowPlaying(
controller: MediaController,
title: String,
artist: String,
album: String,
artworkUri: Uri?
) {
val metadata = MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.setArtworkUri(artworkUri)
.setIsPlayable(true)
.build()
val item = controller.currentMediaItem
?.buildUpon()
?.setMediaMetadata(metadata)
?.build()
?: return
controller.replaceMediaItem(0, item)
}
Mopidy JSON-RPC client
import kotlinx.serialization.json.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
class MopidyClient(private val baseUrl: String) {
private val http = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
private val jsonMediaType = "application/json".toMediaType()
private var idCounter = 1
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonElement {
val body = buildJsonObject {
put("jsonrpc", "2.0")
put("id", idCounter++)
put("method", method)
put("params", params)
}.toString().toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("$baseUrl/api/mopidy/rpc")
.post(body)
.build()
return withContext(Dispatchers.IO) {
http.newCall(request).execute().use { response ->
val text = response.body?.string() ?: "{}"
json.parseToJsonElement(text)
.jsonObject["result"]
?: JsonNull
}
}
}
}
WebSocket for real-time events
import okhttp3.*
class MopidyEventStream(
private val host: String,
private val onEvent: (JsonObject) -> Unit
) {
private val client = OkHttpClient()
private var ws: WebSocket? = null
fun connect() {
val request = Request.Builder()
.url("ws://$host:8180/ws/mopidy")
.build()
ws = client.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
runCatching {
val obj = Json.parseToJsonElement(text).jsonObject
onEvent(obj)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// Reconnect after delay
Thread.sleep(3000)
connect()
}
})
}
fun disconnect() { ws?.close(1000, null) }
}
Suggested Android project structure
app/src/main/java/com/groove/audio/
├── audio/
│ ├── GroovePlaybackService.kt # MediaSessionService (background audio + notification)
│ └── PlayerBuilder.kt # ExoPlayer factory with OkHttp + Basic Auth
├── mopidy/
│ ├── MopidyClient.kt # JSON-RPC over OkHttp
│ ├── MopidyEventStream.kt # WebSocket listener with reconnect
│ └── Models.kt # Track, State data classes
├── ui/
│ ├── NowPlayingScreen.kt # Main Compose screen: artwork, title, controls
│ ├── VolumeSlider.kt # Slider → core.mixer.set_volume
│ └── SettingsScreen.kt # Host, password, connection status
├── AppSettings.kt # SharedPreferences / DataStore wrapper
└── MainActivity.kt # Entry point, binds to GroovePlaybackService
Internet access
The stream server runs on your LAN. To reach it over the internet, choose one approach:
Option A — Tailscale (recommended)
- Install Tailscale on the Pi:
curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up - Install the Tailscale app on iPhone and on the Android device
- All devices join the same Tailscale network
- Use the Pi's Tailscale IP (e.g.
100.x.x.x) as the host in both apps - No port forwarding, no dynamic DNS, encrypted by default over WireGuard
This is the simplest and most secure option for both platforms.
Option B — Port forwarding
- Forward TCP port
8000(Icecast stream) on your router to the Pi's LAN IP (192.168.178.100) - Optionally forward port
8180(web UI / Mopidy API) as well - Use a dynamic DNS service (DuckDNS, Cloudflare, etc.) if you don't have a static public IP
- Change the default listener password in
.envbefore exposing to the internet - On iOS: add the public hostname to
NSExceptionDomainsin Info.plist (plain HTTP) or configure HTTPS - On Android: add the public hostname to the network security config for cleartext, or configure HTTPS
Option C — Cloudflare Tunnel
Cloudflare Tunnels may terminate long-lived unbounded chunked HTTP streams (the FLAC stream). Not recommended for the audio stream; can be used for the Mopidy API endpoint only.
Key URLs summary
| What | LAN URL | Notes |
|---|---|---|
| FLAC stream | http://192.168.178.100:8000/stream.flac |
Auth required |
| FLAC via nginx | http://192.168.178.100:8180/stream.flac |
Same stream, single port |
| Mopidy RPC | http://192.168.178.100:8180/api/mopidy/rpc |
No auth |
| Mopidy WS | ws://192.168.178.100:8180/ws/mopidy |
No auth |
| Icecast admin | http://192.168.178.100:8000/admin/ |
Admin password from .env |
Replace 192.168.178.100 with the Pi's Tailscale IP when connecting over the internet via Option A.