add mobile androida and ios apps
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "GrooveAudio"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user