Add media browser screen with search and library browsing
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user