Add media browser screen with search and library browsing
This commit is contained in:
@@ -29,3 +29,19 @@ data class PlaybackState(
|
|||||||
data class TLTrack(
|
data class TLTrack(
|
||||||
val track: Track? = null
|
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.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
@@ -73,10 +77,136 @@ class MopidyClient(host: String) {
|
|||||||
call("core.playback.play")
|
call("core.playback.play")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun play(tlid: Int) {
|
||||||
|
call("core.playback.play", buildJsonObject {
|
||||||
|
put("tlid", tlid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun pause() {
|
suspend fun pause() {
|
||||||
call("core.playback.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 {
|
companion object {
|
||||||
fun logFailure(tag: String, message: String, error: Throwable) {
|
fun logFailure(tag: String, message: String, error: Throwable) {
|
||||||
Log.e(tag, "$message: ${error.message}", error)
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
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.Settings
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
@@ -54,6 +55,7 @@ fun NowPlayingScreen() {
|
|||||||
var volume by remember { mutableStateOf(70) }
|
var volume by remember { mutableStateOf(70) }
|
||||||
var coverArtUrl by remember { mutableStateOf<String?>(null) }
|
var coverArtUrl by remember { mutableStateOf<String?>(null) }
|
||||||
var showSettings by remember { mutableStateOf(false) }
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
|
var showBrowser by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val mopidyClient = remember { MopidyClient(settings.host) }
|
val mopidyClient = remember { MopidyClient(settings.host) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -67,6 +69,14 @@ fun NowPlayingScreen() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBrowser) {
|
||||||
|
MediaBrowserScreen(
|
||||||
|
mopidyClient = mopidyClient,
|
||||||
|
onBack = { showBrowser = false }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Poll for playback state
|
// Poll for playback state
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -115,6 +125,13 @@ fun NowPlayingScreen() {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
|
IconButton(onClick = { showBrowser = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.LibraryMusic,
|
||||||
|
contentDescription = "Browse library",
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = { showSettings = true }) {
|
IconButton(onClick = { showSettings = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Settings,
|
imageVector = Icons.Default.Settings,
|
||||||
@@ -170,6 +187,17 @@ fun NowPlayingScreen() {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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
|
// Volume Control
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|||||||
Reference in New Issue
Block a user