- Dependency Updates & Cleanup
- Frontend Security Enhancements (Nginx/Entrypoint) - Docker Networking & Binding - Code Bug Fix
This commit is contained in:
@@ -5,7 +5,8 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.groove.audio"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
buildToolsVersion = "35.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.groove.audio"
|
||||
@@ -38,13 +39,14 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Media3 (ExoPlayer successor — recommended for new projects)
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.1")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
|
||||
implementation("androidx.media3:media3-ui:1.4.1")
|
||||
implementation("androidx.media3:media3-session:1.4.1") // MediaSession + notification
|
||||
|
||||
@@ -60,7 +62,8 @@ dependencies {
|
||||
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.material3:material3:1.2.1")
|
||||
implementation("androidx.compose.material:material-icons-extended:1.6.8")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.6.8")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:roundIcon="@drawable/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.GrooveAudio"
|
||||
tools:targetApi="31">
|
||||
@@ -20,7 +21,7 @@
|
||||
<service
|
||||
android:name=".audio.GroovePlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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,
|
||||
@@ -14,24 +12,20 @@ data class Track(
|
||||
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
|
||||
)
|
||||
|
||||
@@ -5,25 +5,32 @@ import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@UnstableApi
|
||||
class MopidyClient(private val baseUrl: String) {
|
||||
class MopidyClient(host: String) {
|
||||
private val baseUrl = "http://${host.trim().removePrefix("http://").removePrefix("https://")}:8180"
|
||||
private val http = OkHttpClient()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val jsonMediaType = "application/json".toMediaType()
|
||||
private var idCounter = 1
|
||||
private val idCounter = AtomicInteger(1)
|
||||
|
||||
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonObject {
|
||||
suspend fun call(method: String, params: JsonObject = buildJsonObject {}): JsonElement {
|
||||
val body = buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", idCounter++)
|
||||
put("id", idCounter.getAndIncrement())
|
||||
put("method", method)
|
||||
put("params", params)
|
||||
}.toString().toRequestBody(jsonMediaType)
|
||||
@@ -35,15 +42,44 @@ class MopidyClient(private val baseUrl: String) {
|
||||
|
||||
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())
|
||||
val text = response.body?.string().orEmpty()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Mopidy HTTP ${response.code}: $text")
|
||||
}
|
||||
|
||||
val obj = json.parseToJsonElement(text).jsonObject
|
||||
obj["error"]?.let { throw IOException("Mopidy RPC error: $it") }
|
||||
obj["result"] ?: JsonNull
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getState(): String =
|
||||
call("core.playback.get_state").jsonPrimitive.content
|
||||
|
||||
suspend fun getCurrentTlTrack(): JsonObject? =
|
||||
(call("core.playback.get_current_tl_track") as? JsonObject)
|
||||
|
||||
suspend fun getVolume(): Int =
|
||||
call("core.mixer.get_volume").jsonPrimitive.content.toIntOrNull() ?: 70
|
||||
|
||||
suspend fun setVolume(volume: Int) {
|
||||
call("core.mixer.set_volume", buildJsonObject {
|
||||
put("volume", volume.coerceIn(0, 100))
|
||||
})
|
||||
}
|
||||
|
||||
suspend fun play() {
|
||||
call("core.playback.play")
|
||||
}
|
||||
|
||||
suspend fun pause() {
|
||||
call("core.playback.pause")
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun logFailure(tag: String, message: String, error: Throwable) {
|
||||
Log.e(tag, "$message: ${error.message}", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.groove.audio.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -13,10 +13,12 @@ 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.Settings
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,9 +37,10 @@ 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.audio.GroovePlaybackService
|
||||
import com.groove.audio.mopidy.MopidyClient
|
||||
import com.groove.audio.mopidy.PlaybackState
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@@ -49,31 +53,41 @@ fun NowPlayingScreen() {
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var volume by remember { mutableStateOf(70) }
|
||||
var coverArtUrl by remember { mutableStateOf<String?>(null) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
|
||||
val mopidyClient = remember { MopidyClient(settings.host) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.startForegroundService(Intent(context, GroovePlaybackService::class.java))
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
SettingsScreen(onBack = { showSettings = false })
|
||||
return
|
||||
}
|
||||
|
||||
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 state = mopidyClient.getState()
|
||||
val track = mopidyClient.getCurrentTlTrack()
|
||||
?.get("track")
|
||||
?.jsonObject
|
||||
val trackName = track?.get("name")?.jsonPrimitive?.content
|
||||
val artists = track?.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"
|
||||
trackName ?: "Unknown Track"
|
||||
}
|
||||
|
||||
isPlaying = state == "playing"
|
||||
} catch (e: Exception) {
|
||||
println("Error fetching playback state: ${e.message}")
|
||||
MopidyClient.logFailure("NowPlayingScreen", "Error fetching playback state", e)
|
||||
}
|
||||
|
||||
// Poll every 3 seconds
|
||||
@@ -84,10 +98,9 @@ fun NowPlayingScreen() {
|
||||
LaunchedEffect(Unit) {
|
||||
// Fetch volume
|
||||
try {
|
||||
val result = mopidyClient.call("core.mixer.getVolume")
|
||||
volume = result.jsonObject["volume"]?.jsonPrimitive?.int ?: 70
|
||||
volume = mopidyClient.getVolume()
|
||||
} catch (e: Exception) {
|
||||
println("Error fetching volume: ${e.message}")
|
||||
MopidyClient.logFailure("NowPlayingScreen", "Error fetching volume", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +111,19 @@ fun NowPlayingScreen() {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(onClick = { showSettings = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Cover Art
|
||||
val painter = rememberAsyncImagePainter(coverArtUrl)
|
||||
Image(
|
||||
@@ -122,14 +148,16 @@ fun NowPlayingScreen() {
|
||||
|
||||
// Play/Pause Button
|
||||
Button(onClick = {
|
||||
try {
|
||||
if (isPlaying) {
|
||||
mopidyClient.call("core.playback.pause")
|
||||
} else {
|
||||
mopidyClient.call("core.playback.play")
|
||||
scope.launch {
|
||||
try {
|
||||
if (isPlaying) {
|
||||
mopidyClient.pause()
|
||||
} else {
|
||||
mopidyClient.play()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
MopidyClient.logFailure("NowPlayingScreen", "Error toggling play/pause", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error toggling play/pause: ${e.message}")
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
@@ -153,11 +181,14 @@ fun NowPlayingScreen() {
|
||||
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}")
|
||||
val newVolume = newValue.toInt()
|
||||
volume = newVolume
|
||||
scope.launch {
|
||||
try {
|
||||
mopidyClient.setVolume(newVolume)
|
||||
} catch (e: Exception) {
|
||||
MopidyClient.logFailure("NowPlayingScreen", "Error setting volume", e)
|
||||
}
|
||||
}
|
||||
},
|
||||
valueRange = 0f..100f
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#101418"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#3DDC97"
|
||||
android:pathData="M38,22h10v42a14,14 0,1 1,-10 -13z" />
|
||||
<path
|
||||
android:fillColor="#61A8FF"
|
||||
android:pathData="M60,22h10v50a14,14 0,1 1,-10 -13z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="groove_background">#101418</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<style name="Theme.GrooveAudio" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/groove_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include domain="sharedpref" path="." />
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include domain="sharedpref" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include domain="sharedpref" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
Reference in New Issue
Block a user