Add media browser screen with search and library browsing

This commit is contained in:
2026-05-19 10:47:32 +02:00
parent 8247692aa2
commit f74d08706d
4 changed files with 667 additions and 0 deletions
@@ -29,3 +29,19 @@ data class PlaybackState(
data class TLTrack(
val track: Track? = null
)
data class MediaBrowserItem(
val name: String,
val uri: String,
val type: String,
val artist: String? = null,
val album: String? = null,
val length: Long? = null,
val imageUrl: String? = null
)
data class MediaSearchResults(
val albums: List<MediaBrowserItem> = emptyList(),
val artists: List<MediaBrowserItem> = emptyList(),
val tracks: List<MediaBrowserItem> = emptyList()
)
@@ -7,8 +7,12 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
@@ -73,10 +77,136 @@ class MopidyClient(host: String) {
call("core.playback.play")
}
suspend fun play(tlid: Int) {
call("core.playback.play", buildJsonObject {
put("tlid", tlid)
})
}
suspend fun pause() {
call("core.playback.pause")
}
suspend fun browse(uri: String): List<MediaBrowserItem> {
val result = call("core.library.browse", buildJsonObject {
put("uri", uri)
})
return result.jsonArray.mapNotNull { it.asBrowserItem() }
}
suspend fun lookup(uri: String): List<MediaBrowserItem> {
val result = call("core.library.lookup", buildJsonObject {
put("uri", uri)
})
return result.jsonArray.mapNotNull { it.asTrackItem() }
}
suspend fun search(query: String, uris: List<String> = listOf("local:")): MediaSearchResults {
val result = call("core.library.search", buildJsonObject {
put("query", buildJsonObject {
put("any", JsonArray(listOf(JsonPrimitive(query))))
})
put("uris", JsonArray(uris.map { JsonPrimitive(it) }))
})
val resultObjects = result.jsonArray.mapNotNull { it as? JsonObject }
val albums = resultObjects
.flatMap { it["albums"]?.jsonArray.orEmpty() }
.mapNotNull { it.asBrowserItem("album") }
.distinctBy { it.uri }
.take(30)
val artists = resultObjects
.flatMap { it["artists"]?.jsonArray.orEmpty() }
.mapNotNull { it.asBrowserItem("artist") }
.distinctBy { it.uri }
.take(30)
val tracks = resultObjects
.flatMap { it["tracks"]?.jsonArray.orEmpty() }
.mapNotNull { it.asTrackItem() }
.distinctBy { it.uri }
.take(80)
return MediaSearchResults(albums = albums, artists = artists, tracks = tracks)
}
suspend fun imageUrls(uris: List<String>): Map<String, String> {
if (uris.isEmpty()) return emptyMap()
val result = call("core.library.get_images", buildJsonObject {
put("uris", JsonArray(uris.distinct().map { JsonPrimitive(it) }))
})
return result.jsonObject.mapNotNull { (uri, images) ->
val imageUri = images.jsonArray.firstOrNull()
?.jsonObject
?.get("uri")
?.jsonPrimitive
?.contentOrNull
imageUri?.let { proxyImageUrl(it)?.let { proxied -> uri to proxied } }
}.toMap()
}
suspend fun addTracks(uris: List<String>) {
if (uris.isEmpty()) return
call("core.tracklist.add", buildJsonObject {
put("uris", JsonArray(uris.map { JsonPrimitive(it) }))
})
}
suspend fun playNow(uris: List<String>) {
if (uris.isEmpty()) return
val added = call("core.tracklist.add", buildJsonObject {
put("uris", JsonArray(uris.map { JsonPrimitive(it) }))
put("at_position", 0)
}).jsonArray
val tlid = added.firstOrNull()
?.jsonObject
?.get("tlid")
?.jsonPrimitive
?.content
?.toIntOrNull()
if (tlid != null) play(tlid) else play()
}
suspend fun refreshLibrary() {
call("core.library.refresh")
}
fun proxyImageUrl(uri: String?): String? {
if (uri.isNullOrBlank()) return null
if (uri.startsWith("http") || uri.startsWith("data:")) return uri
val path = uri.trimStart('/').removePrefix("mopidy/")
return "$baseUrl/api/mopidy-image/$path"
}
private fun JsonElement.asBrowserItem(defaultType: String? = null): MediaBrowserItem? {
val obj = this as? JsonObject ?: return null
val name = obj.string("name") ?: return null
val uri = obj.string("uri") ?: return null
val type = obj.string("type") ?: defaultType ?: "directory"
return MediaBrowserItem(name = name, uri = uri, type = type)
}
private fun JsonElement.asTrackItem(): MediaBrowserItem? {
val obj = this as? JsonObject ?: return null
val name = obj.string("name") ?: return null
val uri = obj.string("uri") ?: return null
val artists = obj["artists"]?.jsonArray
?.mapNotNull { it.jsonObject.string("name") }
?.joinToString(", ")
val album = obj["album"]?.jsonObject?.string("name")
val length = obj["length"]?.jsonPrimitive?.content?.toLongOrNull()
return MediaBrowserItem(
name = name,
uri = uri,
type = "track",
artist = artists,
album = album,
length = length
)
}
private fun JsonObject.string(name: String): String? =
get(name)?.jsonPrimitive?.contentOrNull
companion object {
fun logFailure(tag: String, message: String, error: Throwable) {
Log.e(tag, "$message: ${error.message}", error)
@@ -0,0 +1,493 @@
package com.groove.audio.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Album
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.rememberCoroutineScope
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.util.UnstableApi
import coil.compose.rememberAsyncImagePainter
import com.groove.audio.mopidy.MediaBrowserItem
import com.groove.audio.mopidy.MediaSearchResults
import com.groove.audio.mopidy.MopidyClient
import kotlinx.coroutines.launch
private data class BrowseMode(
val label: String,
val uri: String
)
private data class BrowserCrumb(
val label: String,
val uri: String
)
private val browseModes = listOf(
BrowseMode("Albums", "local:directory?type=album"),
BrowseMode("Artists", "local:directory?type=artist"),
BrowseMode("Genres", "local:directory?type=genre"),
BrowseMode("New", "local:directory?max-age=2592000"),
BrowseMode("Tracks", "local:directory?type=track")
)
@UnstableApi
@Composable
fun MediaBrowserScreen(
mopidyClient: MopidyClient,
onBack: () -> Unit
) {
val scope = rememberCoroutineScope()
var mode by remember { mutableStateOf(browseModes.first()) }
var stack by remember { mutableStateOf(listOf(BrowserCrumb(mode.label, mode.uri))) }
var items by remember { mutableStateOf<List<MediaBrowserItem>>(emptyList()) }
var covers by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
var loading by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<MediaSearchResults?>(null) }
var notice by remember { mutableStateOf<String?>(null) }
val current = stack.last()
val browsing = searchResults == null
fun showNotice(message: String) {
notice = message
}
fun selectMode(next: BrowseMode) {
mode = next
stack = listOf(BrowserCrumb(next.label, next.uri))
searchResults = null
query = ""
}
fun openItem(item: MediaBrowserItem) {
if (item.type == "track") {
scope.launch {
try {
mopidyClient.playNow(listOf(item.uri))
showNotice("Playing ${item.name}")
} catch (error: Exception) {
MopidyClient.logFailure("MediaBrowserScreen", "Error playing track", error)
showNotice("Could not play track")
}
}
} else {
stack = stack + BrowserCrumb(item.name, item.uri)
}
}
fun addToQueue(uris: List<String>, message: String) {
scope.launch {
try {
mopidyClient.addTracks(uris)
showNotice(message)
} catch (error: Exception) {
MopidyClient.logFailure("MediaBrowserScreen", "Error adding tracks", error)
showNotice("Could not add to queue")
}
}
}
fun playNow(uris: List<String>, message: String) {
scope.launch {
try {
mopidyClient.playNow(uris)
showNotice(message)
} catch (error: Exception) {
MopidyClient.logFailure("MediaBrowserScreen", "Error playing tracks", error)
showNotice("Could not play")
}
}
}
LaunchedEffect(current.uri, browsing) {
if (!browsing) return@LaunchedEffect
loading = true
try {
val nextItems = mopidyClient.browse(current.uri)
items = nextItems
covers = mopidyClient.imageUrls(nextItems.filter { it.type != "track" }.map { it.uri })
} catch (error: Exception) {
MopidyClient.logFailure("MediaBrowserScreen", "Error browsing library", error)
items = emptyList()
covers = emptyMap()
showNotice("Could not load library")
} finally {
loading = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Server Music",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = if (browsing) stack.joinToString(" / ") { it.label } else "Search results",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(
onClick = {
scope.launch {
try {
mopidyClient.refreshLibrary()
showNotice("Library scan started")
} catch (error: Exception) {
MopidyClient.logFailure("MediaBrowserScreen", "Error refreshing library", error)
showNotice("Could not scan library")
}
}
}
) {
Icon(Icons.Default.Refresh, contentDescription = "Rescan library")
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
browseModes.forEach { option ->
FilterChip(
selected = browsing && option == mode && stack.size == 1,
onClick = { selectMode(option) },
label = { Text(option.label) }
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("Search server music") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
val trimmed = query.trim()
if (trimmed.isEmpty()) {
searchResults = null
return@Button
}
scope.launch {
loading = true
try {
val results = mopidyClient.search(trimmed)
val albumImages = mopidyClient.imageUrls(results.albums.map { it.uri })
searchResults = results.copy(
albums = results.albums.map { it.copy(imageUrl = albumImages[it.uri]) }
)
} catch (error: Exception) {
MopidyClient.logFailure("MediaBrowserScreen", "Error searching library", error)
searchResults = MediaSearchResults()
showNotice("Search failed")
} finally {
loading = false
}
}
}
) {
Text("Go")
}
}
Spacer(modifier = Modifier.height(10.dp))
notice?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp)
)
}
if (browsing) {
BrowseList(
loading = loading,
items = items.map { it.copy(imageUrl = covers[it.uri]) },
canGoBack = stack.size > 1,
onUp = { if (stack.size > 1) stack = stack.dropLast(1) },
onOpen = ::openItem,
onAdd = { item -> addToQueue(listOf(item.uri), "Added ${item.name}") },
onPlayAll = {
val trackUris = items.filter { it.type == "track" }.map { it.uri }
playNow(trackUris, "Playing ${trackUris.size} tracks")
},
onQueueAll = {
val trackUris = items.filter { it.type == "track" }.map { it.uri }
addToQueue(trackUris, "Queued ${trackUris.size} tracks")
}
)
} else {
SearchList(
loading = loading,
results = searchResults ?: MediaSearchResults(),
onOpen = ::openItem,
onAdd = { item -> addToQueue(listOf(item.uri), "Added ${item.name}") },
onClear = {
searchResults = null
query = ""
}
)
}
}
}
@Composable
private fun BrowseList(
loading: Boolean,
items: List<MediaBrowserItem>,
canGoBack: Boolean,
onUp: () -> Unit,
onOpen: (MediaBrowserItem) -> Unit,
onAdd: (MediaBrowserItem) -> Unit,
onPlayAll: () -> Unit,
onQueueAll: () -> Unit
) {
val trackCount = items.count { it.type == "track" }
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize()
) {
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onUp, enabled = canGoBack) {
Text("Up")
}
OutlinedButton(onClick = onPlayAll, enabled = trackCount > 0) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Play all")
}
OutlinedButton(onClick = onQueueAll, enabled = trackCount > 0) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Queue")
}
}
}
if (loading) {
item { Text("Loading library...") }
} else if (items.isEmpty()) {
item { Text("No music found here.") }
} else {
items(items, key = { it.uri }) { item ->
MediaRow(item = item, onClick = { onOpen(item) }, onAdd = { onAdd(item) })
}
}
}
}
@Composable
private fun SearchList(
loading: Boolean,
results: MediaSearchResults,
onOpen: (MediaBrowserItem) -> Unit,
onAdd: (MediaBrowserItem) -> Unit,
onClear: () -> Unit
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize()
) {
item {
OutlinedButton(onClick = onClear) {
Text("Clear search")
}
}
if (loading) {
item { Text("Searching...") }
return@LazyColumn
}
if (results.albums.isEmpty() && results.artists.isEmpty() && results.tracks.isEmpty()) {
item { Text("No results.") }
return@LazyColumn
}
if (results.albums.isNotEmpty()) {
item { SectionLabel("Albums") }
items(results.albums, key = { it.uri }) { item ->
MediaRow(item = item, onClick = { onOpen(item) }, onAdd = null)
}
}
if (results.artists.isNotEmpty()) {
item { SectionLabel("Artists") }
items(results.artists, key = { it.uri }) { item ->
MediaRow(item = item, onClick = { onOpen(item) }, onAdd = null)
}
}
if (results.tracks.isNotEmpty()) {
item { SectionLabel("Tracks") }
items(results.tracks, key = { it.uri }) { item ->
MediaRow(item = item, onClick = { onOpen(item) }, onAdd = { onAdd(item) })
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp)
)
}
@Composable
private fun MediaRow(
item: MediaBrowserItem,
onClick: () -> Unit,
onAdd: (() -> Unit)?
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Artwork(item)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.name,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val subtitle = listOfNotNull(item.artist, item.album, item.type.takeUnless { it == "track" })
.joinToString(" - ")
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (item.type == "track" && onAdd != null) {
IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add to queue")
}
}
}
}
}
@Composable
private fun Artwork(item: MediaBrowserItem) {
Box(
modifier = Modifier
.size(54.dp)
.aspectRatio(1f)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
if (item.imageUrl != null) {
Image(
painter = rememberAsyncImagePainter(item.imageUrl),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
val icon = when (item.type) {
"artist" -> Icons.Default.Person
"track" -> Icons.Default.LibraryMusic
else -> Icons.Default.Album
}
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -13,6 +13,7 @@ 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.LibraryMusic
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
@@ -54,6 +55,7 @@ fun NowPlayingScreen() {
var volume by remember { mutableStateOf(70) }
var coverArtUrl by remember { mutableStateOf<String?>(null) }
var showSettings by remember { mutableStateOf(false) }
var showBrowser by remember { mutableStateOf(false) }
val mopidyClient = remember { MopidyClient(settings.host) }
val scope = rememberCoroutineScope()
@@ -67,6 +69,14 @@ fun NowPlayingScreen() {
return
}
if (showBrowser) {
MediaBrowserScreen(
mopidyClient = mopidyClient,
onBack = { showBrowser = false }
)
return
}
LaunchedEffect(Unit) {
// Poll for playback state
while (true) {
@@ -115,6 +125,13 @@ fun NowPlayingScreen() {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(onClick = { showBrowser = true }) {
Icon(
imageVector = Icons.Default.LibraryMusic,
contentDescription = "Browse library",
tint = MaterialTheme.colorScheme.onBackground
)
}
IconButton(onClick = { showSettings = true }) {
Icon(
imageVector = Icons.Default.Settings,
@@ -170,6 +187,17 @@ fun NowPlayingScreen() {
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { showBrowser = true }) {
Icon(
imageVector = Icons.Default.LibraryMusic,
contentDescription = null
)
Spacer(modifier = Modifier.size(8.dp))
Text("Browse Library")
}
Spacer(modifier = Modifier.height(24.dp))
// Volume Control
Row(
modifier = Modifier.fillMaxWidth(),