diff --git a/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/Models.kt b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/Models.kt index 4c91d18..f1a43db 100644 --- a/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/Models.kt +++ b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/Models.kt @@ -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 = emptyList(), + val artists: List = emptyList(), + val tracks: List = emptyList() +) diff --git a/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/MopidyClient.kt b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/MopidyClient.kt index a719226..cfa514d 100644 --- a/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/MopidyClient.kt +++ b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/mopidy/MopidyClient.kt @@ -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 { + val result = call("core.library.browse", buildJsonObject { + put("uri", uri) + }) + return result.jsonArray.mapNotNull { it.asBrowserItem() } + } + + suspend fun lookup(uri: String): List { + val result = call("core.library.lookup", buildJsonObject { + put("uri", uri) + }) + return result.jsonArray.mapNotNull { it.asTrackItem() } + } + + suspend fun search(query: String, uris: List = 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): Map { + 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) { + if (uris.isEmpty()) return + call("core.tracklist.add", buildJsonObject { + put("uris", JsonArray(uris.map { JsonPrimitive(it) })) + }) + } + + suspend fun playNow(uris: List) { + 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) diff --git a/GrooveAudio (Android)/app/src/main/java/com/groove/audio/ui/MediaBrowserScreen.kt b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/ui/MediaBrowserScreen.kt new file mode 100644 index 0000000..b3636c6 --- /dev/null +++ b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/ui/MediaBrowserScreen.kt @@ -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>(emptyList()) } + var covers by remember { mutableStateOf>(emptyMap()) } + var loading by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + var searchResults by remember { mutableStateOf(null) } + var notice by remember { mutableStateOf(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, 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, 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, + 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 + ) + } + } +} diff --git a/GrooveAudio (Android)/app/src/main/java/com/groove/audio/ui/NowPlayingScreen.kt b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/ui/NowPlayingScreen.kt index 155e77b..b4648fd 100644 --- a/GrooveAudio (Android)/app/src/main/java/com/groove/audio/ui/NowPlayingScreen.kt +++ b/GrooveAudio (Android)/app/src/main/java/com/groove/audio/ui/NowPlayingScreen.kt @@ -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(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(),