add streaming and mobile-app instructions
This commit is contained in:
@@ -21,6 +21,13 @@ TZ=Europe/Vienna
|
|||||||
# Check with: arecord -l
|
# Check with: arecord -l
|
||||||
HIFIBERRY_CARD=sndrpihifiberry
|
HIFIBERRY_CARD=sndrpihifiberry
|
||||||
|
|
||||||
|
# Icecast2 streaming server passwords
|
||||||
|
ICECAST_SOURCE_PASSWORD=groove_source
|
||||||
|
ICECAST_RELAY_PASSWORD=groove_relay
|
||||||
|
ICECAST_ADMIN_PASSWORD=groove_admin
|
||||||
|
# Listener password — mobile app uses this to authenticate when playing the stream
|
||||||
|
ICECAST_LISTENER_PASSWORD=groove_listen
|
||||||
|
|
||||||
# Spotify credentials for mopidy-spotify
|
# Spotify credentials for mopidy-spotify
|
||||||
SPOTIFY_CLIENT_ID=b168b3fa4c124a5e91cfeb6c2af23fc0
|
SPOTIFY_CLIENT_ID=b168b3fa4c124a5e91cfeb6c2af23fc0
|
||||||
SPOTIFY_CLIENT_SECRET=e357609b4aa74752aca03a9ba58c3978
|
SPOTIFY_CLIENT_SECRET=e357609b4aa74752aca03a9ba58c3978
|
||||||
|
|||||||
+10
-1
@@ -8,7 +8,7 @@ BEOCREATE_HOST=beocreate.local
|
|||||||
BEOCREATE_PORT=13141
|
BEOCREATE_PORT=13141
|
||||||
|
|
||||||
# ── Audio FIFOs ───────────────────────────────────────────────────────────────
|
# ── Audio FIFOs ───────────────────────────────────────────────────────────────
|
||||||
# Host directory containing mopidy.fifo, turntable.fifo, cava.fifo
|
# Host directory containing mopidy.fifo, turntable.fifo, cava.fifo, stream.fifo
|
||||||
# Run ./scripts/init-pipes.sh once to create these
|
# Run ./scripts/init-pipes.sh once to create these
|
||||||
AUDIO_PIPES_DIR=/opt/audiocontrol/pipes
|
AUDIO_PIPES_DIR=/opt/audiocontrol/pipes
|
||||||
|
|
||||||
@@ -28,6 +28,15 @@ TZ=Europe/Vienna
|
|||||||
# Find with: arecord -l
|
# Find with: arecord -l
|
||||||
HIFIBERRY_CARD=sndrpihifiberry
|
HIFIBERRY_CARD=sndrpihifiberry
|
||||||
|
|
||||||
|
# ── Icecast2 streaming ────────────────────────────────────────────────────────
|
||||||
|
# Passwords for the Icecast2 server (port 8000).
|
||||||
|
# stream-relay pushes to /stream.mp3 using ICECAST_SOURCE_PASSWORD.
|
||||||
|
ICECAST_SOURCE_PASSWORD=groove_source
|
||||||
|
ICECAST_RELAY_PASSWORD=groove_relay
|
||||||
|
ICECAST_ADMIN_PASSWORD=groove_admin
|
||||||
|
# Listener password — mobile app authenticates with this
|
||||||
|
ICECAST_LISTENER_PASSWORD=groove_listen
|
||||||
|
|
||||||
# ── Spotify (mopidy-spotify) ──────────────────────────────────────────────────
|
# ── Spotify (mopidy-spotify) ──────────────────────────────────────────────────
|
||||||
# Register an app at https://developer.spotify.com/dashboard
|
# Register an app at https://developer.spotify.com/dashboard
|
||||||
SPOTIFY_CLIENT_ID=
|
SPOTIFY_CLIENT_ID=
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
```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<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
|
||||||
|
|
||||||
|
```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
|
||||||
|
<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`:
|
||||||
|
|
||||||
|
```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.
|
||||||
@@ -32,6 +32,34 @@ services:
|
|||||||
# Snapserver reads FIFOs — needs to stay running even when pipes are empty
|
# Snapserver reads FIFOs — needs to stay running even when pipes are empty
|
||||||
# so it must open them in non-blocking mode (handled by snapserver itself)
|
# so it must open them in non-blocking mode (handled by snapserver itself)
|
||||||
|
|
||||||
|
# ── Icecast2 (HTTP audio stream server) ─────────────────────────────────────
|
||||||
|
icecast:
|
||||||
|
build: ./icecast
|
||||||
|
container_name: icecast
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000" # HTTP stream — mobile/browser clients connect here
|
||||||
|
environment:
|
||||||
|
- ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-groove_source}
|
||||||
|
- ICECAST_RELAY_PASSWORD=${ICECAST_RELAY_PASSWORD:-groove_relay}
|
||||||
|
- ICECAST_ADMIN_PASSWORD=${ICECAST_ADMIN_PASSWORD:-groove_admin}
|
||||||
|
- ICECAST_LISTENER_PASSWORD=${ICECAST_LISTENER_PASSWORD:-groove_listen}
|
||||||
|
|
||||||
|
# ── Stream Relay (PCM FIFO → MP3 → Icecast) ─────────────────────────────────
|
||||||
|
stream-relay:
|
||||||
|
build: ./stream-relay
|
||||||
|
container_name: stream-relay
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- <<: *audio-volume
|
||||||
|
environment:
|
||||||
|
- ICECAST_HOST=icecast
|
||||||
|
- ICECAST_PORT=8000
|
||||||
|
- ICECAST_SOURCE_PASSWORD=${ICECAST_SOURCE_PASSWORD:-groove_source}
|
||||||
|
- ICECAST_MOUNT=/stream.flac
|
||||||
|
depends_on:
|
||||||
|
- icecast
|
||||||
|
|
||||||
# ── Mopidy ───────────────────────────────────────────────────────────────────
|
# ── Mopidy ───────────────────────────────────────────────────────────────────
|
||||||
mopidy:
|
mopidy:
|
||||||
build: ./mopidy
|
build: ./mopidy
|
||||||
@@ -49,6 +77,7 @@ services:
|
|||||||
- <<: *audio-volume
|
- <<: *audio-volume
|
||||||
depends_on:
|
depends_on:
|
||||||
- snapserver
|
- snapserver
|
||||||
|
- stream-relay
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Europe/Vienna}
|
- TZ=${TZ:-Europe/Vienna}
|
||||||
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
|
||||||
|
|||||||
@@ -120,6 +120,20 @@ server {
|
|||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Icecast2 HTTP audio stream ───────────────────────────────────────────
|
||||||
|
# Also available directly on port 8000; this proxy adds a convenient
|
||||||
|
# single-origin URL and lets the mobile app avoid an extra open port.
|
||||||
|
location /stream.flac {
|
||||||
|
set $icecast http://icecast:8000;
|
||||||
|
proxy_pass $icecast/stream.flac;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
}
|
||||||
|
|
||||||
# ── Gzip ─────────────────────────────────────────────────────────────────
|
# ── Gzip ─────────────────────────────────────────────────────────────────
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/javascript application/json;
|
gzip_types text/plain text/css application/javascript application/json;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
icecast2 \
|
||||||
|
gettext-base \
|
||||||
|
apache2-utils \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s \
|
||||||
|
CMD curl -fsS http://localhost:8000/ > /dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["/bin/sh", "-c", "\
|
||||||
|
envsubst '${ICECAST_SOURCE_PASSWORD}${ICECAST_RELAY_PASSWORD}${ICECAST_ADMIN_PASSWORD}' \
|
||||||
|
< /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml \
|
||||||
|
&& htpasswd -cb /etc/icecast2/listeners.htpasswd \
|
||||||
|
listener \"${ICECAST_LISTENER_PASSWORD:-groove_listen}\" \
|
||||||
|
&& exec icecast2 -c /etc/icecast2/icecast.xml"]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<icecast>
|
||||||
|
<location>Groove Audio</location>
|
||||||
|
<admin>admin@localhost</admin>
|
||||||
|
|
||||||
|
<limits>
|
||||||
|
<clients>10</clients>
|
||||||
|
<sources>1</sources>
|
||||||
|
<queue-size>131072</queue-size>
|
||||||
|
<client-timeout>30</client-timeout>
|
||||||
|
<header-timeout>15</header-timeout>
|
||||||
|
<source-timeout>10</source-timeout>
|
||||||
|
<burst-on-connect>1</burst-on-connect>
|
||||||
|
<burst-size>65536</burst-size>
|
||||||
|
</limits>
|
||||||
|
|
||||||
|
<authentication>
|
||||||
|
<source-password>${ICECAST_SOURCE_PASSWORD}</source-password>
|
||||||
|
<relay-password>${ICECAST_RELAY_PASSWORD}</relay-password>
|
||||||
|
<admin-user>admin</admin-user>
|
||||||
|
<admin-password>${ICECAST_ADMIN_PASSWORD}</admin-password>
|
||||||
|
</authentication>
|
||||||
|
|
||||||
|
<hostname>0.0.0.0</hostname>
|
||||||
|
|
||||||
|
<listen-socket>
|
||||||
|
<port>8000</port>
|
||||||
|
</listen-socket>
|
||||||
|
|
||||||
|
<http-headers>
|
||||||
|
<header name="Access-Control-Allow-Origin" value="*" />
|
||||||
|
<header name="Access-Control-Allow-Headers" value="Origin, Accept, X-Requested-With, Content-Type" />
|
||||||
|
<header name="Access-Control-Allow-Methods" value="GET, OPTIONS, HEAD" />
|
||||||
|
</http-headers>
|
||||||
|
|
||||||
|
<!-- Listener authentication on the stream mount.
|
||||||
|
Clients must supply ?username=listener&password=ICECAST_LISTENER_PASSWORD
|
||||||
|
in the URL, or set Authorization: Basic ... header. -->
|
||||||
|
<mount type="normal">
|
||||||
|
<mount-name>/stream.flac</mount-name>
|
||||||
|
<authentication type="htpasswd">
|
||||||
|
<option name="filename" value="/etc/icecast2/listeners.htpasswd"/>
|
||||||
|
<option name="allow_duplicate_users" value="1"/>
|
||||||
|
</authentication>
|
||||||
|
</mount>
|
||||||
|
|
||||||
|
<paths>
|
||||||
|
<basedir>/usr/share/icecast2</basedir>
|
||||||
|
<logdir>/var/log/icecast2</logdir>
|
||||||
|
<webroot>/usr/share/icecast2/web</webroot>
|
||||||
|
<adminroot>/usr/share/icecast2/admin</adminroot>
|
||||||
|
<alias source="/" destination="/status.xsl" />
|
||||||
|
</paths>
|
||||||
|
|
||||||
|
<logging>
|
||||||
|
<accesslog>-</accesslog>
|
||||||
|
<errorlog>-</errorlog>
|
||||||
|
<loglevel>3</loglevel>
|
||||||
|
</logging>
|
||||||
|
|
||||||
|
<security>
|
||||||
|
<chroot>0</chroot>
|
||||||
|
</security>
|
||||||
|
</icecast>
|
||||||
@@ -17,6 +17,23 @@ fi
|
|||||||
|
|
||||||
echo "[mopidy] FIFO ready: $FIFO"
|
echo "[mopidy] FIFO ready: $FIFO"
|
||||||
|
|
||||||
|
STREAM_FIFO=/audio/stream.fifo
|
||||||
|
|
||||||
|
if [[ ! -p "$STREAM_FIFO" ]]; then
|
||||||
|
echo "[mopidy] Creating FIFO: $STREAM_FIFO"
|
||||||
|
mkfifo "$STREAM_FIFO"
|
||||||
|
chmod 666 "$STREAM_FIFO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Hold stream.fifo open O_RDWR in a background process.
|
||||||
|
# GStreamer's filesink opens the FIFO write-only, which blocks until a reader
|
||||||
|
# exists. This holder acts as that persistent reader so the pipeline can start
|
||||||
|
# regardless of whether stream-relay is up. leaky=1 on the GStreamer queue
|
||||||
|
# ensures Mopidy never blocks even if the relay is slow.
|
||||||
|
(while true; do exec 6<>"$STREAM_FIFO"; sleep 86400; done) &
|
||||||
|
|
||||||
|
echo "[mopidy] stream.fifo ready: $STREAM_FIFO"
|
||||||
|
|
||||||
# Load Spotify credentials from shared config volume (written by the settings UI).
|
# Load Spotify credentials from shared config volume (written by the settings UI).
|
||||||
# Values here take precedence over env vars from docker-compose / .env.
|
# Values here take precedence over env vars from docker-compose / .env.
|
||||||
_SPOTIFY_JSON=/config/shared/spotify.json
|
_SPOTIFY_JSON=/config/shared/spotify.json
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ config_file =
|
|||||||
[audio]
|
[audio]
|
||||||
mixer = software
|
mixer = software
|
||||||
mixer_volume =
|
mixer_volume =
|
||||||
output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! tee name=t ! queue max-size-buffers=0 max-size-bytes=0 max-size-time=2000000000 ! filesink location=/audio/mopidy.fifo sync=true async=false t. ! queue leaky=2 max-size-buffers=0 max-size-bytes=0 max-size-time=200000000 ! filesink location=/audio/cava.fifo sync=true async=false
|
output = audioresample ! audioconvert ! audio/x-raw,rate=48000,channels=2,format=S16LE ! tee name=t ! queue max-size-buffers=0 max-size-bytes=0 max-size-time=2000000000 ! filesink location=/audio/mopidy.fifo sync=true async=false t. ! queue leaky=2 max-size-buffers=0 max-size-bytes=0 max-size-time=200000000 ! filesink location=/audio/cava.fifo sync=true async=false t. ! queue leaky=1 max-size-buffers=0 max-size-bytes=0 max-size-time=2000000000 ! filesink location=/audio/stream.fifo sync=false async=false
|
||||||
buffer_time =
|
buffer_time =
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ DIR="${1:-/opt/audiocontrol/pipes}"
|
|||||||
echo "Creating audio pipe directory: $DIR"
|
echo "Creating audio pipe directory: $DIR"
|
||||||
mkdir -p "$DIR"
|
mkdir -p "$DIR"
|
||||||
|
|
||||||
for PIPE in mopidy.fifo turntable.fifo cava.fifo; do
|
for PIPE in mopidy.fifo turntable.fifo cava.fifo stream.fifo; do
|
||||||
FULL="$DIR/$PIPE"
|
FULL="$DIR/$PIPE"
|
||||||
if [[ -p "$FULL" ]]; then
|
if [[ -p "$FULL" ]]; then
|
||||||
echo " ✓ $PIPE (already exists)"
|
echo " ✓ $PIPE (already exists)"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
FIFO=/audio/stream.fifo
|
||||||
|
ICECAST_HOST=${ICECAST_HOST:-icecast}
|
||||||
|
ICECAST_PORT=${ICECAST_PORT:-8000}
|
||||||
|
ICECAST_PASSWORD=${ICECAST_SOURCE_PASSWORD:-groove_source}
|
||||||
|
MOUNT=${ICECAST_MOUNT:-/stream.flac}
|
||||||
|
|
||||||
|
until [ -p "$FIFO" ]; do
|
||||||
|
echo "[stream-relay] Waiting for $FIFO..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Open FIFO with O_RDWR so Mopidy's GStreamer filesink never blocks on open().
|
||||||
|
# A write-only open blocks until a reader exists; by holding the FIFO open
|
||||||
|
# O_RDWR here, the write side can proceed immediately at any time.
|
||||||
|
exec 5<>"$FIFO"
|
||||||
|
|
||||||
|
echo "[stream-relay] FIFO open. Encoding PCM → FLAC → icecast://${ICECAST_HOST}:${ICECAST_PORT}${MOUNT}"
|
||||||
|
|
||||||
|
ICECAST_URL="icecast://source:${ICECAST_PASSWORD}@${ICECAST_HOST}:${ICECAST_PORT}${MOUNT}"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
ffmpeg -nostdin \
|
||||||
|
-f s16le -ar 48000 -ac 2 \
|
||||||
|
-i "$FIFO" \
|
||||||
|
-vn \
|
||||||
|
-c:a flac \
|
||||||
|
-compression_level 0 \
|
||||||
|
-f flac \
|
||||||
|
-content_type audio/flac \
|
||||||
|
"$ICECAST_URL" 2>&1 || true
|
||||||
|
echo "[stream-relay] ffmpeg exited — reconnecting in 3s..."
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user