add mobile androida and ios apps

This commit is contained in:
2026-05-18 22:30:45 +02:00
parent 0e40a23828
commit 6146bf18b0
32 changed files with 1594 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# GrooveAudio - Android
Android client for the GrooveAudio streaming server.
## Features
- Stream FLAC audio from your Mopidy server
- Control playback (play/pause)
- Adjust volume
- View now-playing information
- Background playback with notification
- Lock screen controls
## Setup
1. Open the project in Android Studio
2. Build and run on your device
## Configuration
The app stores connection settings in SharedPreferences:
- Host: IP address of your Mopidy server (default: `192.168.178.100`)
- Password: Listener password for the Icecast stream (default: `groove_listen`)
## Architecture
- **Player**: Media3 ExoPlayer with OkHttp for authentication
- **Service**: MediaSessionService for background playback and notification
- **Mopidy Client**: JSON-RPC over HTTP using OkHttp
- **Events**: WebSocket for real-time updates
- **UI**: Jetpack Compose
## Dependencies
- Media3 ExoPlayer: Audio playback
- OkHttp: HTTP client for Mopidy API and WebSocket
- Coil: Image loading for cover art
- Kotlinx Serialization: JSON parsing
- Jetpack Compose: UI framework
## Notes
- The app uses port 8000 for the FLAC stream and port 8180 for the Mopidy API
- For internet access, see the [MOBILE_APP_INSTRUCTIONS.md](../../MOBILE_APP_INSTRUCTIONS.md) in the root directory
- Background playback requires Android 8.0+ (API 26+)
@@ -0,0 +1,66 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.groove.audio"
compileSdk = 35
defaultConfig {
applicationId = "com.groove.audio"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
}
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")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.compose.ui:ui:1.6.8")
implementation("androidx.compose.material:material:1.6.8")
implementation("androidx.compose.ui:ui-tooling-preview:1.6.8")
implementation("androidx.navigation:navigation-compose:2.7.7")
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<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" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GrooveAudio"
tools:targetApi="31">
<service
android:name=".audio.GroovePlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.GrooveAudio">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,32 @@
package com.groove.audio
import android.content.Context
import androidx.media3.common.util.UnstableApi
@UnstableApi
class AppSettings(private val context: Context) {
private val prefs = context.getSharedPreferences("groove", Context.MODE_PRIVATE)
var host: String
get() = prefs.getString("host", "192.168.178.100") ?: "192.168.178.100"
set(value) {
prefs.edit().putString("host", value).apply()
}
var password: String
get() = prefs.getString("password", "groove_listen") ?: "groove_listen"
set(value) {
prefs.edit().putString("password", value).apply()
}
companion object {
@Volatile
private var instance: AppSettings? = null
fun getInstance(context: Context): AppSettings {
return instance ?: synchronized(this) {
instance ?: AppSettings(context).also { instance = it }
}
}
}
}
@@ -0,0 +1,26 @@
package com.groove.audio
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.media3.common.util.UnstableApi
import com.groove.audio.ui.NowPlayingScreen
@UnstableApi
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
NowPlayingScreen()
}
}
}
}
@@ -0,0 +1,34 @@
package com.groove.audio.audio
import android.content.Context
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
@UnstableApi
class GroovePlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
val prefs = getSharedPreferences("groove", Context.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()
}
}
@@ -0,0 +1,43 @@
package com.groove.audio.audio
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import okhttp3.OkHttpClient
import okhttp3.Request
@UnstableApi
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 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
}
@@ -0,0 +1,37 @@
package com.groove.audio.mopidy
import androidx.media3.common.util.UnstableApi
import kotlinx.serialization.Serializable
@UnstableApi
@Serializable
data class Track(
val name: String? = null,
val artists: List<Artist>? = null,
val album: Album? = null,
val length: Long? = null,
val trackNo: Int? = null,
val metadata: Map<String, String>? = null
)
@Serializable
data class Artist(
val name: String? = null
)
@Serializable
data class Album(
val name: String? = null,
val artists: List<Artist>? = null
)
@Serializable
data class PlaybackState(
val state: String? = null,
val tlTrack: TLTrack? = null
)
@Serializable
data class TLTrack(
val track: Track? = null
)
@@ -0,0 +1,49 @@
package com.groove.audio.mopidy
import android.util.Log
import androidx.media3.common.util.UnstableApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@UnstableApi
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 {}): JsonObject {
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() ?: "{}"
try {
val obj = json.parseToJsonElement(text).jsonObject
obj["result"]?.jsonObject ?: JsonObject(emptyMap())
} catch (e: Exception) {
Log.e("MopidyClient", "JSON parse error: ${e.message}", e)
JsonObject(emptyMap())
}
}
}
}
}
@@ -0,0 +1,47 @@
package com.groove.audio.mopidy
import android.util.Log
import androidx.media3.common.util.UnstableApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
@UnstableApi
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)
}.onFailure { e ->
Log.e("MopidyEventStream", "Message parse error: ${e.message}", e)
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
Log.e("MopidyEventStream", "WebSocket failure: ${t.message}", t)
// Reconnect after delay
Thread.sleep(3000)
connect()
}
})
}
fun disconnect() {
ws?.close(1000, null)
}
}
@@ -0,0 +1,167 @@
package com.groove.audio.ui
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import coil.compose.rememberAsyncImagePainter
import com.groove.audio.AppSettings
import com.groove.audio.mopidy.MopidyClient
import com.groove.audio.mopidy.PlaybackState
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@UnstableApi
@Composable
fun NowPlayingScreen() {
val context = LocalContext.current
val settings = remember { AppSettings.getInstance(context) }
var currentTrack by remember { mutableStateOf<String?>(null) }
var isPlaying by remember { mutableStateOf(false) }
var volume by remember { mutableStateOf(70) }
var coverArtUrl by remember { mutableStateOf<String?>(null) }
val mopidyClient = remember { MopidyClient(settings.host) }
LaunchedEffect(Unit) {
// Poll for playback state
while (true) {
try {
val result = mopidyClient.call("core.playback.getState")
val state = result.jsonObject["state"]?.jsonPrimitive?.content
val track = result.jsonObject["tl_track"]?.jsonObject
val trackName = track?.jsonObject?.get("track")?.jsonObject?.get("name")?.jsonPrimitive?.content
val artists = track?.jsonObject?.get("track")?.jsonObject?.get("artists")?.jsonArray
val artistName = artists?.getOrNull(0)?.jsonObject?.get("name")?.jsonPrimitive?.content
val albumName = track?.jsonObject?.get("track")?.jsonObject?.get("album")?.jsonObject?.get("name")?.jsonPrimitive?.content
val coverArt = track?.jsonObject?.get("track")?.jsonObject?.get("metadata")?.jsonObject?.get("images")?.jsonArray
currentTrack = if (trackName != null && artistName != null) {
"$trackName - $artistName"
} else {
"Unknown Track"
}
isPlaying = state == "playing"
} catch (e: Exception) {
println("Error fetching playback state: ${e.message}")
}
// Poll every 3 seconds
kotlinx.coroutines.delay(3000)
}
}
LaunchedEffect(Unit) {
// Fetch volume
try {
val result = mopidyClient.call("core.mixer.getVolume")
volume = result.jsonObject["volume"]?.jsonPrimitive?.int ?: 70
} catch (e: Exception) {
println("Error fetching volume: ${e.message}")
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Cover Art
val painter = rememberAsyncImagePainter(coverArtUrl)
Image(
painter = painter,
contentDescription = "Album Art",
modifier = Modifier
.aspectRatio(1f)
.fillMaxWidth(),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(16.dp))
// Track Info
Text(
text = currentTrack ?: "No track playing",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(8.dp))
// Play/Pause Button
Button(onClick = {
try {
if (isPlaying) {
mopidyClient.call("core.playback.pause")
} else {
mopidyClient.call("core.playback.play")
}
} catch (e: Exception) {
println("Error toggling play/pause: ${e.message}")
}
}) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play"
)
Spacer(modifier = Modifier.size(8.dp))
Text(if (isPlaying) "Pause" else "Play")
}
Spacer(modifier = Modifier.height(24.dp))
// Volume Control
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(text = "Volume: $volume%")
Spacer(modifier = Modifier.size(16.dp))
Slider(
value = volume.toFloat(),
onValueChange = { newValue ->
volume = newValue.toInt()
try {
mopidyClient.call("core.mixer.setVolume", JsonObject(mapOf("volume" to volume)))
} catch (e: Exception) {
println("Error setting volume: ${e.message}")
}
},
valueRange = 0f..100f
)
}
}
}
@@ -0,0 +1,75 @@
package com.groove.audio.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import com.groove.audio.AppSettings
@UnstableApi
@Composable
fun SettingsScreen(onBack: () -> Unit) {
val context = LocalContext.current
val settings = remember { AppSettings.getInstance(context) }
var host by remember { mutableStateOf(settings.host) }
var password by remember { mutableStateOf(settings.password) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Connection Settings",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 32.dp)
)
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
placeholder = { Text("192.168.178.100") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
placeholder = { Text("groove_listen") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = {
settings.host = host
settings.password = password
onBack()
}) {
Text("Save")
}
}
}
@@ -0,0 +1,28 @@
package com.groove.audio.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
@UnstableApi
@Composable
fun VolumeSlider(volume: Int, onVolumeChange: (Int) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = "Volume: $volume%")
Spacer(modifier = Modifier.height(8.dp))
Slider(
value = volume.toFloat(),
onValueChange = { newValue ->
onVolumeChange(newValue.toInt())
},
valueRange = 0f..100f
)
}
}
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">GrooveAudio</string>
</resources>
+24
View File
@@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all subprojects/modules.
buildscript {
ext.kotlin_version = "1.9.24"
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.4.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
+18
View File
@@ -0,0 +1,18 @@
# Project-wide Gradle settings.
# IDE (see https://developer.android.com/studio/intro/study#build)
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.enableJetifier=true
android.useAndroidX=true
# Kotlin code style for this project
kotlin.code.style=official
# Enable composition for this project
android.experimental.enableComposeCompiler=true
# Enable view binding
android.experimental.enableViewBinding=true
# Enable data binding
android.experimental.enableDataBinding=true
+7
View File
@@ -0,0 +1,7 @@
# Local properties for the project
# The SDK location can be customized, but the default is the following
# sdk.dir=D\\/Android\\Sdk
# Android NDK location
# ndk.dir=D\\/Android\\Sdk\\ndk\\25.2.9519653
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "GrooveAudio"
include(":app")