## 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://:8000/stream.flac` | HTTP chunked (FLAC) | Lossless audio stream | | `http://:8180/api/mopidy/rpc` | HTTP JSON-RPC | Mopidy playback control | | `ws://:8180/ws/mopidy` | WebSocket | Real-time Mopidy events | | `http://: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_PASSWORD` in the server's `.env` (default: `groove_listen`) The app must pass `Authorization: Basic ` 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`. ```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":[""]}} // 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://:8180/ws/mopidy`. The server pushes JSON event objects whenever playback changes. Listen for: - `event: "track_playback_started"` — new track; contains `tl_track.track` - `event: "track_playback_paused"` / `"track_playback_resumed"` - `event: "volume_changed"` — contains `volume` --- ## App requirements (both platforms) ### Core features 1. **Audio streaming** — play the FLAC stream continuously in the background 2. **Now-playing screen** — show track title, artist, album, and cover art 3. **Transport controls** — play/pause, next, previous 4. **Volume slider** — controls Mopidy software mixer volume (not device volume) 5. **Background audio** — stream continues when app is backgrounded 6. **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 `AVFoundation` and `Combine` frameworks (no additional packages needed) - Enable **Background Modes** capability: check `Audio, AirPlay, and Picture in Picture` - Set `NSAppTransportSecurity > NSAllowsLocalNetworking` to `YES` in Info.plist (for LAN HTTP) - If connecting over the internet via plain HTTP, add the server hostname to `NSExceptionDomains` or use HTTPS (see internet access section) ### Audio streaming — AVPlayer with authentication ```swift 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) ```swift 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 ```swift struct MopidyClient { let baseURL: String // e.g. "http://192.168.178.100:8180" private var nextID = 1 mutating func call(_ 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.self, from: data) return wrapper.result } } ``` ### WebSocket for real-time events ```swift 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`: ```kotlin 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`: ```xml ``` - 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 `` tag for development. ### Audio streaming — Media3 ExoPlayer with authentication ExoPlayer handles HTTP FLAC streaming natively. Inject the `Authorization` header via a custom `DataSource.Factory`: ```kotlin 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: ```kotlin 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: ```kotlin 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 ```kotlin 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 ```kotlin 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) 1. Install Tailscale on the Pi: `curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up` 2. Install the Tailscale app on iPhone **and** on the Android device 3. All devices join the same Tailscale network 4. Use the Pi's Tailscale IP (e.g. `100.x.x.x`) as the host in both apps 5. 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 1. Forward TCP port `8000` (Icecast stream) on your router to the Pi's LAN IP (`192.168.178.100`) 2. Optionally forward port `8180` (web UI / Mopidy API) as well 3. Use a dynamic DNS service (DuckDNS, Cloudflare, etc.) if you don't have a static public IP 4. Change the default listener password in `.env` before exposing to the internet 5. On iOS: add the public hostname to `NSExceptionDomains` in Info.plist (plain HTTP) or configure HTTPS 6. 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.