Files
audioserver/MOBILE_APP_INSTRUCTIONS.md
T

19 KiB

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_PASSWORD in 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; 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

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:

  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.