Compare commits
2
Commits
62383accf7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2ae6adf8
|
||
|
|
163fa6c87e
|
@@ -137,6 +137,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media)
|
implementation(libs.androidx.media)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.androidx.palette)
|
||||||
|
|
||||||
// The Compose BOM aligns every Compose artifact to one tested version set,
|
// The Compose BOM aligns every Compose artifact to one tested version set,
|
||||||
// so the individual Compose deps below are declared without versions.
|
// so the individual Compose deps below are declared without versions.
|
||||||
|
|||||||
@@ -4,52 +4,40 @@ import android.Manifest
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import ca.ksamad.encore.playback.VolumeKeyDispatcher
|
|
||||||
import ca.ksamad.encore.ui.EncoreApp
|
import ca.ksamad.encore.ui.EncoreApp
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val requestNotificationPermission =
|
private val requestNotificationPermission =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
|
||||||
|
|
||||||
// Intercept the hardware volume keys while we're focused so they drive the
|
|
||||||
// server volume without the system slider appearing (see VolumeKeyDispatcher).
|
|
||||||
private val volumeKeys by lazy {
|
|
||||||
VolumeKeyDispatcher((application as EncoreApplication).manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
maybeRequestNotificationPermission()
|
maybeRequestNotificationPermission()
|
||||||
setContent {
|
setContent {
|
||||||
EncoreTheme {
|
EncoreTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
// No inset padding here: each screen's own Scaffold/TopAppBar (or, for the
|
||||||
Surface(
|
// plain-Column screens, their own systemBarsPadding) applies the system-bar
|
||||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
// insets. Doing it at the root too would double the status-bar gap.
|
||||||
color = MaterialTheme.colorScheme.background,
|
Surface(
|
||||||
) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
EncoreApp()
|
color = MaterialTheme.colorScheme.background,
|
||||||
}
|
) {
|
||||||
|
EncoreApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
|
||||||
volumeKeys.dispatch(event) || super.dispatchKeyEvent(event)
|
|
||||||
|
|
||||||
// The media notification needs POST_NOTIFICATIONS on Android 13+. Without it
|
// The media notification needs POST_NOTIFICATIONS on Android 13+. Without it
|
||||||
// the foreground service still runs and cast volume still works, but the
|
// the foreground service still runs and cast volume still works, but the
|
||||||
// now-playing notification / QS controls won't show.
|
// now-playing notification / QS controls won't show.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class SettingsRepository(private val context: Context) {
|
|||||||
val PORT = intPreferencesKey("port")
|
val PORT = intPreferencesKey("port")
|
||||||
val PASSWORD = stringPreferencesKey("password")
|
val PASSWORD = stringPreferencesKey("password")
|
||||||
val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode")
|
val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode")
|
||||||
|
val VOLUME_STEP = intPreferencesKey("volume_step")
|
||||||
|
val GRID_COLUMNS = intPreferencesKey("grid_columns")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How the albums library is laid out; emits on every change, defaulted when unset. */
|
/** How the albums library is laid out; emits on every change, defaulted when unset. */
|
||||||
@@ -36,6 +38,36 @@ class SettingsRepository(private val context: Context) {
|
|||||||
context.dataStore.edit { prefs -> prefs[Keys.ALBUM_VIEW_MODE] = mode.name }
|
context.dataStore.edit { prefs -> prefs[Keys.ALBUM_VIEW_MODE] = mode.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many percent each hardware volume-key press moves the server volume; emits on every
|
||||||
|
* change, clamped into range and defaulted when unset.
|
||||||
|
*/
|
||||||
|
val volumeStep: Flow<Int> =
|
||||||
|
context.dataStore.data.map { prefs ->
|
||||||
|
(prefs[Keys.VOLUME_STEP] ?: DEFAULT_VOLUME_STEP).coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setVolumeStep(step: Int) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.VOLUME_STEP] = step.coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many columns the albums grid layout uses; emits on every change, clamped into range and
|
||||||
|
* defaulted when unset.
|
||||||
|
*/
|
||||||
|
val gridColumns: Flow<Int> =
|
||||||
|
context.dataStore.data.map { prefs ->
|
||||||
|
(prefs[Keys.GRID_COLUMNS] ?: DEFAULT_GRID_COLUMNS).coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setGridColumns(columns: Int) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.GRID_COLUMNS] = columns.coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The saved settings, or `null` if the user has never connected (no host persisted yet).
|
* The saved settings, or `null` if the user has never connected (no host persisted yet).
|
||||||
* Callers use the null case to show first-run UI / decide whether to auto-connect.
|
* Callers use the null case to show first-run UI / decide whether to auto-connect.
|
||||||
@@ -70,4 +102,16 @@ class SettingsRepository(private val context: Context) {
|
|||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
context.dataStore.edit { it.clear() }
|
context.dataStore.edit { it.clear() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Volume-step bounds and default (percent per hardware volume-key press). */
|
||||||
|
const val DEFAULT_VOLUME_STEP = 1
|
||||||
|
const val MIN_VOLUME_STEP = 1
|
||||||
|
const val MAX_VOLUME_STEP = 10
|
||||||
|
|
||||||
|
/** Albums-grid column-count bounds and default. */
|
||||||
|
const val DEFAULT_GRID_COLUMNS = 2
|
||||||
|
const val MIN_GRID_COLUMNS = 1
|
||||||
|
const val MAX_GRID_COLUMNS = 4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,12 +129,15 @@ class MpdClient(
|
|||||||
/** Tear everything down; returns to [MpdConnectionState.Disconnected]. */
|
/** Tear everything down; returns to [MpdConnectionState.Disconnected]. */
|
||||||
suspend fun disconnect() {
|
suspend fun disconnect() {
|
||||||
shuttingDown = true
|
shuttingDown = true
|
||||||
stopBackgroundJobs()
|
// Close the sockets *before* joining the loops. The idle loop is parked in a blocking
|
||||||
// Closing the idle socket unblocks the loop's parked `idle` read.
|
// socket read that coroutine cancellation cannot interrupt, so closing the socket is what
|
||||||
|
// unblocks it. If we joined first (as this used to), disconnect() would hang until the
|
||||||
|
// server happened to push an idle event — which on a quiet server can be a very long time.
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
idleConn?.close()
|
idleConn?.close()
|
||||||
commandConn?.close()
|
commandConn?.close()
|
||||||
}
|
}
|
||||||
|
stopBackgroundJobs()
|
||||||
closeArtConnections()
|
closeArtConnections()
|
||||||
idleConn = null
|
idleConn = null
|
||||||
commandConn = null
|
commandConn = null
|
||||||
@@ -237,6 +240,21 @@ class MpdClient(
|
|||||||
/** Trigger a full re-read of the database, including files with an unchanged mtime. */
|
/** Trigger a full re-read of the database, including files with an unchanged mtime. */
|
||||||
suspend fun rescanDatabase() = run(MpdCommands.rescan())
|
suspend fun rescanDatabase() = run(MpdCommands.rescan())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the queue with [uris] (in the given order) and start playing the first. Used when the
|
||||||
|
* user taps a track to play an album from that track onward. No-op on an empty list.
|
||||||
|
*/
|
||||||
|
suspend fun playTracks(uris: List<String>) {
|
||||||
|
if (uris.isEmpty()) return
|
||||||
|
withCommand { conn ->
|
||||||
|
conn.executeList(
|
||||||
|
MpdCommands.clear(),
|
||||||
|
*uris.map { MpdCommands.add(it) }.toTypedArray(),
|
||||||
|
MpdCommands.play(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Replace the queue with an album and start playing it. */
|
/** Replace the queue with an album and start playing it. */
|
||||||
suspend fun playAlbum(
|
suspend fun playAlbum(
|
||||||
album: String,
|
album: String,
|
||||||
@@ -364,8 +382,13 @@ class MpdClient(
|
|||||||
// Cover fetches run on their own small pool of connections rather than the
|
// Cover fetches run on their own small pool of connections rather than the
|
||||||
// command connection, so (a) many covers load in parallel while scrolling and
|
// command connection, so (a) many covers load in parallel while scrolling and
|
||||||
// (b) art never blocks play/pause/volume. Connections are opened lazily, up
|
// (b) art never blocks play/pause/volume. Connections are opened lazily, up
|
||||||
// to ART_POOL_SIZE (bounded by the semaphore), reused when idle, and dropped
|
// to ART_POOL_SIZE (bounded by the semaphore), and reused when idle.
|
||||||
// on any transport error (a stale/timed-out one just gets reopened).
|
//
|
||||||
|
// Unlike the command connection, these carry no keepalive, so MPD reaps them
|
||||||
|
// after its connection_timeout (idle art is fetched rarely). A borrowed pooled
|
||||||
|
// connection may therefore be dead; reusing it throws a transport error. When
|
||||||
|
// that happens we discard it and retry once on a freshly opened connection, so
|
||||||
|
// a stale socket can't blank a cover until the app is restarted.
|
||||||
|
|
||||||
private val artSemaphore = Semaphore(ART_POOL_SIZE)
|
private val artSemaphore = Semaphore(ART_POOL_SIZE)
|
||||||
private val artPoolLock = Any()
|
private val artPoolLock = Any()
|
||||||
@@ -373,33 +396,54 @@ class MpdClient(
|
|||||||
|
|
||||||
private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
|
private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
|
||||||
artSemaphore.withPermit {
|
artSemaphore.withPermit {
|
||||||
val conn = borrowArtConnection()
|
// First try a pooled connection, if any. A transport failure here means
|
||||||
try {
|
// it went stale — discard it (runArt already closed it) and fall through
|
||||||
val result = withContext(ioDispatcher) { block(conn) }
|
// to a fresh one. A server ACK is a real result and is not retried.
|
||||||
returnArtConnection(conn)
|
borrowPooledArtConnection()?.let { pooled ->
|
||||||
result
|
try {
|
||||||
} catch (e: MpdAckException) {
|
return@withPermit runArt(pooled, block)
|
||||||
returnArtConnection(conn) // command refused, but connection is healthy
|
} catch (e: MpdAckException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Throwable) {
|
} catch (e: IOException) {
|
||||||
runCatching { conn.close() } // discard a broken connection
|
// stale pooled connection; retry on a fresh one below
|
||||||
throw e
|
}
|
||||||
}
|
}
|
||||||
|
runArt(openArtConnection(), block)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun borrowArtConnection(): MpdConnection {
|
/**
|
||||||
|
* Run [block] on [conn], returning it to the pool on success (or a clean `ACK`, which leaves the
|
||||||
|
* connection healthy) and closing it on any transport failure.
|
||||||
|
*/
|
||||||
|
private suspend fun <T> runArt(
|
||||||
|
conn: MpdConnection,
|
||||||
|
block: (MpdConnection) -> T,
|
||||||
|
): T =
|
||||||
|
try {
|
||||||
|
val result = withContext(ioDispatcher) { block(conn) }
|
||||||
|
returnArtConnection(conn)
|
||||||
|
result
|
||||||
|
} catch (e: MpdAckException) {
|
||||||
|
returnArtConnection(conn) // command refused, but connection is healthy
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
runCatching { conn.close() } // discard a broken connection
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pop an idle pooled art connection, or null if the pool is empty. */
|
||||||
|
private fun borrowPooledArtConnection(): MpdConnection? =
|
||||||
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }
|
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }
|
||||||
?.let {
|
|
||||||
return it
|
/** Open, authenticate, and prime a brand-new art connection. */
|
||||||
}
|
private suspend fun openArtConnection(): MpdConnection =
|
||||||
return withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
val h = host ?: throw MpdConnectionException("not connected")
|
val h = host ?: throw MpdConnectionException("not connected")
|
||||||
val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
|
val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
|
||||||
password?.let { conn.execute(MpdCommands.password(it)) }
|
password?.let { conn.execute(MpdCommands.password(it)) }
|
||||||
runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
|
runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
|
||||||
conn
|
conn
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun returnArtConnection(conn: MpdConnection) {
|
private fun returnArtConnection(conn: MpdConnection) {
|
||||||
val kept =
|
val kept =
|
||||||
@@ -483,8 +527,11 @@ class MpdClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reconnectLoop() {
|
private suspend fun reconnectLoop() {
|
||||||
stopBackgroundJobs()
|
// Close first, then join — same reason as disconnect(): closing the sockets unblocks the
|
||||||
|
// idle loop's parked blocking read so stopBackgroundJobs() doesn't wait on it. (The idle
|
||||||
|
// loop's own error handler no-ops here because a reconnect is already in flight.)
|
||||||
closeConnectionsQuietly()
|
closeConnectionsQuietly()
|
||||||
|
stopBackgroundJobs()
|
||||||
_connectionState.value = MpdConnectionState.Connecting
|
_connectionState.value = MpdConnectionState.Connecting
|
||||||
var attempt = 0
|
var attempt = 0
|
||||||
while (scope.isActive && !shuttingDown) {
|
while (scope.isActive && !shuttingDown) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ data class MpdSong(
|
|||||||
val disc: String?,
|
val disc: String?,
|
||||||
val date: String?,
|
val date: String?,
|
||||||
val genre: String?,
|
val genre: String?,
|
||||||
|
val lastModified: String?, // "Last-Modified" ISO timestamp; used to cache-bust cover art
|
||||||
|
val format: String?, // "Format" audio format "samplerate:bits:channels" (from the DB scan)
|
||||||
val duration: Double?, // from "duration" (fractional) or legacy "Time"
|
val duration: Double?, // from "duration" (fractional) or legacy "Time"
|
||||||
val pos: Int?, // queue position, present in queue listings
|
val pos: Int?, // queue position, present in queue listings
|
||||||
val id: Int?, // stable queue id, present in queue listings
|
val id: Int?, // stable queue id, present in queue listings
|
||||||
@@ -39,6 +41,8 @@ data class MpdSong(
|
|||||||
disc = values["Disc"],
|
disc = values["Disc"],
|
||||||
date = values["Date"],
|
date = values["Date"],
|
||||||
genre = values["Genre"],
|
genre = values["Genre"],
|
||||||
|
lastModified = values["Last-Modified"],
|
||||||
|
format = values["Format"],
|
||||||
duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(),
|
duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(),
|
||||||
pos = values["Pos"]?.toIntOrNull(),
|
pos = values["Pos"]?.toIntOrNull(),
|
||||||
id = values["Id"]?.toIntOrNull(),
|
id = values["Id"]?.toIntOrNull(),
|
||||||
|
|||||||
@@ -41,10 +41,16 @@ object ArtImageLoader {
|
|||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Stable cache key per art request; shared by the keyer and the disk cache. */
|
/**
|
||||||
|
* Stable cache key per art request; shared by the keyer and the disk cache. For [SongArt] the
|
||||||
|
* optional [SongArt.version] is appended so a re-tagged file (new mtime) lands on a fresh key
|
||||||
|
* and refetches; album keys stay version-free so an album's tracks share one entry.
|
||||||
|
*/
|
||||||
private fun cacheKey(data: MpdArtData): String =
|
private fun cacheKey(data: MpdArtData): String =
|
||||||
when (data) {
|
when (data) {
|
||||||
is SongArt -> "song:${data.uri}"
|
is SongArt ->
|
||||||
|
if (data.version.isNullOrEmpty()) "song:${data.uri}"
|
||||||
|
else "song:${data.uri}@${data.version}"
|
||||||
is AlbumArt -> "album:${data.albumArtist.orEmpty()}/${data.album}"
|
is AlbumArt -> "album:${data.albumArtist.orEmpty()}/${data.album}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,24 @@ package ca.ksamad.encore.playback
|
|||||||
*/
|
*/
|
||||||
sealed interface MpdArtData
|
sealed interface MpdArtData
|
||||||
|
|
||||||
/** Cover art for a specific song URI (used for now-playing and the notification). */
|
/**
|
||||||
data class SongArt(val uri: String) : MpdArtData
|
* Cover art for a specific song URI (used for now-playing and the notification).
|
||||||
|
*
|
||||||
|
* [version] is an opaque cache-bust token — typically the file's `Last-Modified` timestamp. It has
|
||||||
|
* no effect on how the art is fetched (only [uri] matters for that); it only participates in the
|
||||||
|
* cache key, so when a track is re-tagged/re-imported (its mtime changes) the new art is fetched
|
||||||
|
* automatically instead of serving a stale cached copy.
|
||||||
|
*/
|
||||||
|
data class SongArt(
|
||||||
|
val uri: String,
|
||||||
|
val version: String? = null,
|
||||||
|
) : MpdArtData
|
||||||
|
|
||||||
/** Cover art for an album (a representative track is resolved server-side). */
|
/**
|
||||||
|
* Cover art for an album (a representative track is resolved server-side). Deliberately has no
|
||||||
|
* per-track version: every track of an album must resolve to one cache entry (see the queue), so
|
||||||
|
* album art is refreshed via the manual "Refresh artwork" action rather than mtime.
|
||||||
|
*/
|
||||||
data class AlbumArt(
|
data class AlbumArt(
|
||||||
val album: String,
|
val album: String,
|
||||||
val albumArtist: String?,
|
val albumArtist: String?,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ca.ksamad.encore.mpd.MpdConnectionState
|
|||||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
import ca.ksamad.encore.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -56,6 +57,30 @@ class MpdConnectionManager(context: Context) {
|
|||||||
scope.launch { settingsRepo.setAlbumViewMode(mode) }
|
scope.launch { settingsRepo.setAlbumViewMode(mode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persisted volume-key step (percent per press). */
|
||||||
|
val volumeStep: StateFlow<Int> =
|
||||||
|
settingsRepo.volumeStep.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
SettingsRepository.DEFAULT_VOLUME_STEP,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setVolumeStep(step: Int) {
|
||||||
|
scope.launch { settingsRepo.setVolumeStep(step) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisted albums-grid column count. */
|
||||||
|
val gridColumns: StateFlow<Int> =
|
||||||
|
settingsRepo.gridColumns.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
SettingsRepository.DEFAULT_GRID_COLUMNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setGridColumns(columns: Int) {
|
||||||
|
scope.launch { settingsRepo.setGridColumns(columns) }
|
||||||
|
}
|
||||||
|
|
||||||
// True until the startup auto-connect decision has been made.
|
// True until the startup auto-connect decision has been made.
|
||||||
private val _bootstrapping = MutableStateFlow(true)
|
private val _bootstrapping = MutableStateFlow(true)
|
||||||
val bootstrapping: StateFlow<Boolean> = _bootstrapping
|
val bootstrapping: StateFlow<Boolean> = _bootstrapping
|
||||||
@@ -176,6 +201,9 @@ class MpdConnectionManager(context: Context) {
|
|||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
) = fire { playAlbumNext(album, albumArtist) }
|
) = fire { playAlbumNext(album, albumArtist) }
|
||||||
|
|
||||||
|
/** Replace the queue with [uris] (in order) and start playing the first. */
|
||||||
|
fun playTracks(uris: List<String>) = fire { playTracks(uris) }
|
||||||
|
|
||||||
/** Append a single track to the end of the queue. */
|
/** Append a single track to the end of the queue. */
|
||||||
fun queueTrack(uri: String) = fire { queueTrack(uri) }
|
fun queueTrack(uri: String) = fire { queueTrack(uri) }
|
||||||
|
|
||||||
@@ -218,6 +246,19 @@ class MpdConnectionManager(context: Context) {
|
|||||||
suspend fun loadQueue(): List<MpdSong> =
|
suspend fun loadQueue(): List<MpdSong> =
|
||||||
runCatching { client.queue() }.getOrDefault(emptyList())
|
runCatching { client.queue() }.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every cached cover (memory + disk) so the next load refetches from the server. Backs the
|
||||||
|
* "Refresh artwork" action — for art that isn't mtime-keyed (album covers, and folder cover
|
||||||
|
* files MPD serves without touching the track's mtime), this is the way to pick up changes made
|
||||||
|
* on the server. Album-keyed art then reloads as each screen is reopened.
|
||||||
|
*/
|
||||||
|
fun refreshArtwork() {
|
||||||
|
val loader = SingletonImageLoader.get(appContext)
|
||||||
|
loader.memoryCache?.clear()
|
||||||
|
// Disk clear touches the filesystem — keep it off the main thread.
|
||||||
|
scope.launch(Dispatchers.IO) { loader.diskCache?.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch cover-art bytes for Coil (null on miss/failure). */
|
/** Fetch cover-art bytes for Coil (null on miss/failure). */
|
||||||
suspend fun fetchArt(data: MpdArtData): ByteArray? =
|
suspend fun fetchArt(data: MpdArtData): ByteArray? =
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -228,19 +269,11 @@ class MpdConnectionManager(context: Context) {
|
|||||||
}
|
}
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|
||||||
/**
|
/** Relative volume change (from the media session's remote VolumeProvider), accumulating. */
|
||||||
* True when we own the volume: connected to a server that exposes a mixer. Used to decide
|
|
||||||
* whether hardware volume keys drive the *server* (silently, in-app) or fall through to the
|
|
||||||
* device's own local volume.
|
|
||||||
*/
|
|
||||||
val isControllingVolume: Boolean
|
|
||||||
get() =
|
|
||||||
connectionState.value is MpdConnectionState.Connected && status.value?.volume != null
|
|
||||||
|
|
||||||
/** Relative volume change (from a hardware key / VolumeProvider), accumulating. */
|
|
||||||
fun nudgeVolume(up: Boolean) {
|
fun nudgeVolume(up: Boolean) {
|
||||||
val base = pendingVolume ?: status.value?.volume ?: return
|
val base = pendingVolume ?: status.value?.volume ?: return
|
||||||
val next = (base + if (up) VOLUME_STEP else -VOLUME_STEP).coerceIn(0, 100)
|
val step = volumeStep.value
|
||||||
|
val next = (base + if (up) step else -step).coerceIn(0, 100)
|
||||||
pendingVolume = next
|
pendingVolume = next
|
||||||
setVolume(next)
|
setVolume(next)
|
||||||
}
|
}
|
||||||
@@ -250,7 +283,6 @@ class MpdConnectionManager(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val VOLUME_STEP = 5
|
|
||||||
const val VOLUME_THROTTLE_MS = 120L
|
const val VOLUME_THROTTLE_MS = 120L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ class PlaybackService : Service() {
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collectLatest { uri ->
|
.collectLatest { uri ->
|
||||||
artUri = uri
|
artUri = uri
|
||||||
artBitmap = if (uri != null) loadArtBitmap(uri) else null
|
val version = manager.currentSong.value?.takeIf { it.uri == uri }?.lastModified
|
||||||
|
artBitmap = if (uri != null) loadArtBitmap(uri, version) else null
|
||||||
if (artUri == uri) {
|
if (artUri == uri) {
|
||||||
session.setMetadata(
|
session.setMetadata(
|
||||||
buildMetadata(manager.currentSong.value, manager.status.value)
|
buildMetadata(manager.currentSong.value, manager.status.value)
|
||||||
@@ -164,10 +165,15 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadArtBitmap(uri: String): Bitmap? {
|
private suspend fun loadArtBitmap(
|
||||||
|
uri: String,
|
||||||
|
version: String?,
|
||||||
|
): Bitmap? {
|
||||||
val result =
|
val result =
|
||||||
SingletonImageLoader.get(applicationContext)
|
SingletonImageLoader.get(applicationContext)
|
||||||
.execute(ImageRequest.Builder(applicationContext).data(SongArt(uri)).build())
|
.execute(
|
||||||
|
ImageRequest.Builder(applicationContext).data(SongArt(uri, version)).build()
|
||||||
|
)
|
||||||
return (result as? SuccessResult)?.image?.toBitmap()
|
return (result as? SuccessResult)?.image?.toBitmap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package ca.ksamad.encore.playback
|
|
||||||
|
|
||||||
import android.view.KeyEvent
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encapsulates hardware volume-key handling for the foreground Activity.
|
|
||||||
*
|
|
||||||
* While the app is focused, we want the volume keys to drive the *server* volume **silently** —
|
|
||||||
* without the system's volume slider popping up (the app already shows its own). The trick is to
|
|
||||||
* fully consume the key event in the Activity so it never reaches the OS volume handling that draws
|
|
||||||
* that slider.
|
|
||||||
*
|
|
||||||
* When the app is backgrounded the Activity isn't in the dispatch path at all, so the
|
|
||||||
* `MediaSession`'s `VolumeProvider` handles the keys instead — there the OS remote-volume UI
|
|
||||||
* showing up is the expected, cast-style behaviour.
|
|
||||||
*
|
|
||||||
* Only volume keys we actually act on are consumed: when we're not controlling the server (e.g. the
|
|
||||||
* connect screen), the event passes through so the device adjusts its own local volume normally.
|
|
||||||
*/
|
|
||||||
class VolumeKeyDispatcher(private val manager: MpdConnectionManager) {
|
|
||||||
/**
|
|
||||||
* Offer [event] to the volume handler. Returns `true` if it was a volume key we handled and
|
|
||||||
* consumed (caller should then *not* pass it on), `false` to let normal dispatch continue.
|
|
||||||
*/
|
|
||||||
fun dispatch(event: KeyEvent): Boolean {
|
|
||||||
val up =
|
|
||||||
when (event.keyCode) {
|
|
||||||
KeyEvent.KEYCODE_VOLUME_UP -> true
|
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN -> false
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
if (!manager.isControllingVolume) return false
|
|
||||||
|
|
||||||
// Nudge on each key-down (auto-repeats included, so holding keeps
|
|
||||||
// changing); swallow the key-up too so no system UI flashes.
|
|
||||||
if (event.action == KeyEvent.ACTION_DOWN) manager.nudgeVolume(up)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.palette.graphics.Palette
|
||||||
|
import ca.ksamad.encore.playback.SongArt
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.request.allowHardware
|
||||||
|
import coil3.size.Size
|
||||||
|
import coil3.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// Small in-memory cache of extracted accent colours (ARGB ints), keyed by the art's cache key, so
|
||||||
|
// revisiting a song — or a recomposition — never re-runs Palette.
|
||||||
|
private val accentCache = LruCache<String, Int>(64)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dominant accent colour of [art]'s cover, used for the now-playing background bleed.
|
||||||
|
*
|
||||||
|
* Pulls a small bitmap from Coil (served from its memory/disk cache, so no extra network request),
|
||||||
|
* runs [Palette] off the main thread, and memoises the result. Returns [fallback] until it resolves,
|
||||||
|
* and whenever there's no art or extraction fails — so callers can just use the value directly.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberArtAccentColor(
|
||||||
|
art: SongArt?,
|
||||||
|
fallback: Color,
|
||||||
|
): Color {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return produceState(initialValue = fallback, art) {
|
||||||
|
if (art == null) {
|
||||||
|
value = fallback
|
||||||
|
return@produceState
|
||||||
|
}
|
||||||
|
val key = "${art.uri}@${art.version.orEmpty()}"
|
||||||
|
accentCache.get(key)?.let {
|
||||||
|
value = Color(it)
|
||||||
|
return@produceState
|
||||||
|
}
|
||||||
|
val argb =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
val loader = SingletonImageLoader.get(context)
|
||||||
|
val result =
|
||||||
|
loader.execute(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(art)
|
||||||
|
.size(Size(128, 128)) // tiny: Palette downsamples anyway
|
||||||
|
.allowHardware(false) // Palette must read pixels on the CPU
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val bitmap =
|
||||||
|
(result as? SuccessResult)?.image?.toBitmap() ?: return@runCatching null
|
||||||
|
val palette = Palette.from(bitmap).maximumColorCount(16).generate()
|
||||||
|
val swatch =
|
||||||
|
palette.dominantSwatch ?: palette.vibrantSwatch ?: palette.mutedSwatch
|
||||||
|
swatch?.let { 0xFF000000.toInt() or it.rgb } // force opaque
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
if (argb != null) {
|
||||||
|
accentCache.put(key, argb)
|
||||||
|
value = Color(argb)
|
||||||
|
} else {
|
||||||
|
value = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.value
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -48,9 +50,9 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One album, in detail: its cover, whole-album Play / Add-to-queue actions, and the track list.
|
* One album, in detail: its cover, whole-album Play / Add-to-queue actions, and the track list.
|
||||||
* Tapping Play replaces the queue and starts the album (leaving this screen); swiping a track
|
* Tapping Play replaces the queue and starts the album (leaving this screen); tapping a track does
|
||||||
* queues it (right) or plays it next (left), with the same semantics as swiping an album in the
|
* the same but starts the album from that track onward. Swiping a track queues it (right) or plays
|
||||||
* library.
|
* it next (left), with the same semantics as swiping an album in the library.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -66,6 +68,10 @@ fun AlbumDetailScreen(
|
|||||||
value = vm.loadAlbumTracks(album.name, album.albumArtist)
|
value = vm.loadAlbumTracks(album.name, album.albumArtist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The album's audio format, taken from the tracks we already loaded (MPD reports a per-song
|
||||||
|
// `Format` in the track listing), so no extra network call. Null until tracks arrive.
|
||||||
|
val albumFormat = remember(tracks) { tracks?.let(::representativeFormat) }
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -91,6 +97,7 @@ fun AlbumDetailScreen(
|
|||||||
item {
|
item {
|
||||||
AlbumDetailHeader(
|
AlbumDetailHeader(
|
||||||
album = album,
|
album = album,
|
||||||
|
format = albumFormat,
|
||||||
onPlay = {
|
onPlay = {
|
||||||
vm.playAlbum(album.name, album.albumArtist)
|
vm.playAlbum(album.name, album.albumArtist)
|
||||||
onPlay()
|
onPlay()
|
||||||
@@ -102,11 +109,21 @@ fun AlbumDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One track row, wired to its swipe actions. Reused whether the list is
|
// One track row, wired to its tap + swipe actions. Reused whether the list
|
||||||
// flat or split into disc groups.
|
// is flat or split into disc groups.
|
||||||
val trackItem: @Composable (MpdSong) -> Unit = { track ->
|
val trackItem: @Composable (MpdSong) -> Unit = { track ->
|
||||||
TrackRow(
|
TrackRow(
|
||||||
track = track,
|
track = track,
|
||||||
|
onPlay = {
|
||||||
|
// Play the album from this track on: replace the queue with the
|
||||||
|
// tapped track and everything after it (the list is already in
|
||||||
|
// disc/track order), then start playing. Stay on the album page —
|
||||||
|
// just flash a confirmation rather than jumping to now-playing.
|
||||||
|
val loaded = tracks ?: return@TrackRow
|
||||||
|
val fromHere = loaded.dropWhile { it.uri != track.uri }.map { it.uri }
|
||||||
|
vm.playTracks(fromHere)
|
||||||
|
flash("Playing “${track.title ?: track.uri}”")
|
||||||
|
},
|
||||||
onQueue = {
|
onQueue = {
|
||||||
vm.queueTrack(track.uri)
|
vm.queueTrack(track.uri)
|
||||||
flash("Added “${track.title ?: track.uri}” to the queue")
|
flash("Added “${track.title ?: track.uri}” to the queue")
|
||||||
@@ -163,10 +180,11 @@ fun AlbumDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cover, title/artist, and the two whole-album action buttons. */
|
/** Cover, title/artist, audio-format pills, and the two whole-album action buttons. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlbumDetailHeader(
|
private fun AlbumDetailHeader(
|
||||||
album: MpdAlbum,
|
album: MpdAlbum,
|
||||||
|
format: String?,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onQueue: () -> Unit,
|
onQueue: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -177,7 +195,7 @@ private fun AlbumDetailHeader(
|
|||||||
ArtImage(
|
ArtImage(
|
||||||
model = AlbumArt(album.name, album.albumArtist),
|
model = AlbumArt(album.name, album.albumArtist),
|
||||||
iconSize = 72.dp,
|
iconSize = 72.dp,
|
||||||
modifier = Modifier.size(220.dp).clip(RoundedCornerShape(12.dp)),
|
modifier = Modifier.fillMaxWidth(0.85f).aspectRatio(1f).clip(RoundedCornerShape(12.dp)),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(16.dp))
|
Spacer(Modifier.size(16.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -197,6 +215,7 @@ private fun AlbumDetailHeader(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AudioFormatPills(pills = audioFormatPills(format), modifier = Modifier.padding(top = 10.dp))
|
||||||
Spacer(Modifier.size(16.dp))
|
Spacer(Modifier.size(16.dp))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -215,18 +234,21 @@ private fun AlbumDetailHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single track row. Swiping right queues the track, swiping left plays it next — the same gesture
|
* A single track row. Tapping plays the album from this track onward (replacing the queue); swiping
|
||||||
* as the album library, scoped to this one track. The leading slot shows the track number (all
|
* right queues just this track and swiping left plays it next — the same gestures as the album
|
||||||
* tracks share the album's cover, so a per-track thumbnail would be redundant here).
|
* library, scoped to this one track. The leading slot shows the track number (all tracks share the
|
||||||
|
* album's cover, so a per-track thumbnail would be redundant here).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackRow(
|
private fun TrackRow(
|
||||||
track: MpdSong,
|
track: MpdSong,
|
||||||
|
onPlay: () -> Unit,
|
||||||
onQueue: () -> Unit,
|
onQueue: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
) {
|
) {
|
||||||
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
||||||
ListItem(
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(onClick = onPlay),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
@@ -266,3 +288,16 @@ private fun trackNumberOf(track: MpdSong): Int? =
|
|||||||
/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */
|
/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */
|
||||||
private fun discNumberOf(track: MpdSong): Int =
|
private fun discNumberOf(track: MpdSong): Int =
|
||||||
track.disc?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 1
|
track.disc?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The album's representative audio format: the most common per-track `Format`
|
||||||
|
* (`samplerate:bits:channels`) among the loaded tracks. Null when no track reports one. Using the
|
||||||
|
* mode keeps a stray transcoded/hidden track from misrepresenting the album.
|
||||||
|
*/
|
||||||
|
private fun representativeFormat(tracks: List<MpdSong>): String? =
|
||||||
|
tracks
|
||||||
|
.mapNotNull { it.format }
|
||||||
|
.groupingBy { it }
|
||||||
|
.eachCount()
|
||||||
|
.maxByOrNull { it.value }
|
||||||
|
?.key
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
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.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -69,10 +71,15 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
fun AlbumsScreen(
|
fun AlbumsScreen(
|
||||||
vm: PlayerViewModel,
|
vm: PlayerViewModel,
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
listState: LazyListState,
|
||||||
|
gridState: LazyGridState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenAlbum: (MpdAlbum) -> Unit,
|
onOpenAlbum: (MpdAlbum) -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
||||||
|
val gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// null = still loading.
|
// null = still loading.
|
||||||
val albums by
|
val albums by
|
||||||
@@ -84,9 +91,10 @@ fun AlbumsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side search over the already-loaded index (album title + album artist).
|
// The search text is hoisted (owned by EncoreApp) so it survives drilling into
|
||||||
var searching by remember { mutableStateOf(false) }
|
// an album and coming back. Whether the search bar is *open* is local, but it
|
||||||
var query by remember { mutableStateOf("") }
|
// starts open whenever there's a restored query so returning re-shows the search.
|
||||||
|
var searching by remember { mutableStateOf(query.isNotEmpty()) }
|
||||||
|
|
||||||
// Transient confirmation for the swipe actions (add-to-queue / play-next).
|
// Transient confirmation for the swipe actions (add-to-queue / play-next).
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -128,7 +136,7 @@ fun AlbumsScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
if (searching) {
|
if (searching) {
|
||||||
AlbumSearchField(query = query, onQueryChange = { query = it })
|
AlbumSearchField(query = query, onQueryChange = onQueryChange)
|
||||||
} else {
|
} else {
|
||||||
Text(filtered?.let { "Albums (${it.size})" } ?: "Albums")
|
Text(filtered?.let { "Albums (${it.size})" } ?: "Albums")
|
||||||
}
|
}
|
||||||
@@ -138,7 +146,7 @@ fun AlbumsScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (searching) {
|
if (searching) {
|
||||||
searching = false
|
searching = false
|
||||||
query = ""
|
onQueryChange("")
|
||||||
} else {
|
} else {
|
||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
@@ -149,7 +157,7 @@ fun AlbumsScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (searching) {
|
if (searching) {
|
||||||
IconButton(onClick = { query = "" }, enabled = query.isNotEmpty()) {
|
IconButton(onClick = { onQueryChange("") }, enabled = query.isNotEmpty()) {
|
||||||
Icon(Icons.Filled.Clear, contentDescription = "Clear search")
|
Icon(Icons.Filled.Clear, contentDescription = "Clear search")
|
||||||
}
|
}
|
||||||
} else if (current != null) {
|
} else if (current != null) {
|
||||||
@@ -187,7 +195,8 @@ fun AlbumsScreen(
|
|||||||
|
|
||||||
viewMode == AlbumViewMode.Grid -> {
|
viewMode == AlbumViewMode.Grid -> {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(2),
|
columns = GridCells.Fixed(gridColumns),
|
||||||
|
state = gridState,
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.padding(innerPadding),
|
||||||
contentPadding = PaddingValues(12.dp),
|
contentPadding = PaddingValues(12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -200,7 +209,7 @@ fun AlbumsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
LazyColumn(state = listState, modifier = Modifier.padding(innerPadding)) {
|
||||||
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
||||||
SwipeableAlbumRow(
|
SwipeableAlbumRow(
|
||||||
album = album,
|
album = album,
|
||||||
@@ -291,8 +300,10 @@ private fun AlbumGridCell(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard;
|
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard
|
||||||
* the surrounding [TopAppBar] handles clearing/closing.
|
* only on a fresh (empty) open — when it reappears already populated (e.g. restored after returning
|
||||||
|
* from an album) it shows the query and results without stealing focus. The surrounding [TopAppBar]
|
||||||
|
* handles clearing/closing.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -321,5 +332,6 @@ private fun AlbumSearchField(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
// Only grab focus for a fresh search; a restored (non-empty) query shouldn't pop the keyboard.
|
||||||
|
LaunchedEffect(Unit) { if (query.isEmpty()) focusRequester.requestFocus() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of little rounded pills describing an audio format — sample rate, bit depth, and channel
|
||||||
|
* layout (e.g. "44.1 kHz", "16-bit", "Stereo"). Shared by now-playing (the live stream) and album
|
||||||
|
* detail (an album's stored format). Renders nothing when [pills] is empty.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AudioFormatPills(
|
||||||
|
pills: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (pills.isEmpty()) return
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
pills.forEach { pill ->
|
||||||
|
Text(
|
||||||
|
text = pill,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier =
|
||||||
|
Modifier.clip(RoundedCornerShape(50))
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an MPD audio-format string, `samplerate:bits:channels` (e.g. `"44100:16:2"`), into pill
|
||||||
|
* labels. The middle token can be `f` (float) or `dsd` instead of a bit depth. Only well-formed
|
||||||
|
* parts appear; a null/garbage input yields an empty list.
|
||||||
|
*/
|
||||||
|
fun audioFormatPills(format: String?): List<String> {
|
||||||
|
val parts = format?.split(":")?.takeIf { it.size >= 3 } ?: return emptyList()
|
||||||
|
return buildList {
|
||||||
|
parts[0].toIntOrNull()?.let { add(formatSampleRate(it)) }
|
||||||
|
formatSampleFormat(parts[1])?.let { add(it) }
|
||||||
|
formatChannels(parts[2])?.let { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSampleRate(hz: Int): String {
|
||||||
|
val khz = hz / 1000.0
|
||||||
|
val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz)
|
||||||
|
return "$value kHz"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSampleFormat(token: String): String? =
|
||||||
|
when (token) {
|
||||||
|
"f" -> "Float"
|
||||||
|
"dsd" -> "DSD"
|
||||||
|
else -> token.toIntOrNull()?.let { "$it-bit" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatChannels(token: String): String? =
|
||||||
|
when (token.toIntOrNull()) {
|
||||||
|
null -> null
|
||||||
|
1 -> "Mono"
|
||||||
|
2 -> "Stereo"
|
||||||
|
else -> "$token ch"
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
@@ -63,6 +65,14 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
||||||
// When set (within the Albums overlay), the album detail screen is shown.
|
// When set (within the Albums overlay), the album detail screen is shown.
|
||||||
var detailAlbum by rememberSaveable(stateSaver = AlbumSaver) { mutableStateOf<MpdAlbum?>(null) }
|
var detailAlbum by rememberSaveable(stateSaver = AlbumSaver) { mutableStateOf<MpdAlbum?>(null) }
|
||||||
|
// Owned here (not in AlbumsScreen) so the search text survives drilling into an
|
||||||
|
// album and back — AlbumsScreen leaves composition while the detail is shown.
|
||||||
|
var albumSearch by rememberSaveable { mutableStateOf("") }
|
||||||
|
// Same reasoning for the library's scroll position (one per layout), so backing
|
||||||
|
// out of an album returns you to where you were. rememberLazy*State is itself
|
||||||
|
// saveable, so these also survive rotation/process death.
|
||||||
|
val albumListState = rememberLazyListState()
|
||||||
|
val albumGridState = rememberLazyGridState()
|
||||||
|
|
||||||
// Overlays only make sense over the player; leaving it (e.g. after a
|
// Overlays only make sense over the player; leaving it (e.g. after a
|
||||||
// reset/disconnect) drops us back to the normal screen flow.
|
// reset/disconnect) drops us back to the normal screen flow.
|
||||||
@@ -73,6 +83,13 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forget the album search once we leave the library entirely (to the player or
|
||||||
|
// another overlay). The detail screen keeps overlay == Albums, so browsing into
|
||||||
|
// an album and back preserves it.
|
||||||
|
LaunchedEffect(overlay) {
|
||||||
|
if (overlay != PlayerOverlay.Albums) albumSearch = ""
|
||||||
|
}
|
||||||
|
|
||||||
when (val s = screen) {
|
when (val s = screen) {
|
||||||
is AppScreen.Loading -> {
|
is AppScreen.Loading -> {
|
||||||
LoadingScreen()
|
LoadingScreen()
|
||||||
@@ -115,6 +132,10 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
} else {
|
} else {
|
||||||
AlbumsScreen(
|
AlbumsScreen(
|
||||||
vm,
|
vm,
|
||||||
|
query = albumSearch,
|
||||||
|
onQueryChange = { albumSearch = it },
|
||||||
|
listState = albumListState,
|
||||||
|
gridState = albumGridState,
|
||||||
onBack = { overlay = PlayerOverlay.None },
|
onBack = { overlay = PlayerOverlay.None },
|
||||||
onOpenAlbum = { detailAlbum = it },
|
onOpenAlbum = { detailAlbum = it },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -12,10 +13,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
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.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Cast
|
import androidx.compose.material.icons.filled.Cast
|
||||||
@@ -27,8 +30,8 @@ import androidx.compose.material.icons.filled.Settings
|
|||||||
import androidx.compose.material.icons.filled.Shuffle
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.material.icons.filled.SkipNext
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
import androidx.compose.material.icons.filled.SkipPrevious
|
import androidx.compose.material.icons.filled.SkipPrevious
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -45,15 +48,53 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
import ca.ksamad.encore.mpd.model.PlayerState
|
import ca.ksamad.encore.mpd.model.PlayerState
|
||||||
import ca.ksamad.encore.playback.SongArt
|
import ca.ksamad.encore.playback.SongArt
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/** Largest the flexible album art is allowed to grow, so it doesn't sprawl on tablets/foldables. */
|
||||||
|
private val ART_MAX_SIZE = 480.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum usable height at which we trust the whole screen fits without scrolling (and switch to the
|
||||||
|
* flexible, art-fills-the-slack layout). Below this — landscape, small phones — we scroll instead so
|
||||||
|
* the lower controls never get clipped.
|
||||||
|
*/
|
||||||
|
private val FLEX_MIN_HEIGHT = 740.dp
|
||||||
|
|
||||||
|
// --- Album-art colour-bleed tuning ---
|
||||||
|
// A 3-stop radial fade (centre → mid → base) with no flat plateau, so the colour fades continuously
|
||||||
|
// out from the cover. The strengths are how far the accent is blended toward the base (0 = base,
|
||||||
|
// 1 = full accent).
|
||||||
|
/** Accent blend at the cover centre (strongest). */
|
||||||
|
private const val BLEED_STRENGTH = 0.55f
|
||||||
|
|
||||||
|
/** Accent blend at [BLEED_MID_STOP] — the tail of the fade. */
|
||||||
|
private const val BLEED_MID_STRENGTH = 0.22f
|
||||||
|
|
||||||
|
/** Radius fraction at which the mid colour sits (shapes the falloff curve). */
|
||||||
|
private const val BLEED_MID_STOP = 0.5f
|
||||||
|
|
||||||
|
/** Gradient radius as a multiple of the cover's larger side — larger = more gradual, wider spread. */
|
||||||
|
private const val BLEED_RADIUS_FACTOR = 1.9f
|
||||||
|
|
||||||
|
/** Crossfade duration (ms) when the accent changes on a new song. */
|
||||||
|
private const val BLEED_ANIM_MS = 700
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The now-playing screen: current track, a live seek bar, transport controls, volume, and the
|
* The now-playing screen: current track, a live seek bar, transport controls, volume, and the
|
||||||
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
|
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
|
||||||
@@ -68,152 +109,255 @@ fun NowPlayingScreen(
|
|||||||
) {
|
) {
|
||||||
val status by vm.status.collectAsStateWithLifecycle()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
val song by vm.currentSong.collectAsStateWithLifecycle()
|
val song by vm.currentSong.collectAsStateWithLifecycle()
|
||||||
val serverHost by vm.serverHost.collectAsStateWithLifecycle()
|
var showDisconnectConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
// --- Album-art colour bleed ------------------------------------------------
|
||||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
// Pull the cover's dominant colour and radiate it from the cover's on-screen position so the
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
// artwork looks like it bleeds into the page. Centre/radius come from the art's measured bounds
|
||||||
|
// (captureCover); the gradient draws edge-to-edge behind everything (before systemBarsPadding).
|
||||||
|
val base = MaterialTheme.colorScheme.background
|
||||||
|
val accent =
|
||||||
|
rememberArtAccentColor(song?.let { SongArt(it.uri, it.lastModified) }, fallback = base)
|
||||||
|
val animatedAccent by animateColorAsState(accent, tween(BLEED_ANIM_MS), label = "artAccent")
|
||||||
|
var coverCenter by remember { mutableStateOf(Offset.Unspecified) }
|
||||||
|
var coverRadius by remember { mutableFloatStateOf(0f) }
|
||||||
|
fun captureCover(coords: LayoutCoordinates) {
|
||||||
|
val pos = coords.positionInRoot()
|
||||||
|
val w = coords.size.width.toFloat()
|
||||||
|
val h = coords.size.height.toFloat()
|
||||||
|
coverCenter = Offset(pos.x + w / 2f, pos.y + h / 2f)
|
||||||
|
coverRadius = maxOf(w, h) * BLEED_RADIUS_FACTOR
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a tall enough screen, lay everything out without scrolling and let the album art
|
||||||
|
// flex into the leftover vertical space (capped at ART_MAX_SIZE) — bigger displays show
|
||||||
|
// bigger art, smaller ones just shrink it. On a short viewport (landscape, small phones)
|
||||||
|
// fall back to a scrolling column with a fixed art size so the lower controls stay reachable.
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.drawBehind {
|
||||||
|
val c = if (coverCenter.isSpecified) coverCenter else center
|
||||||
|
val r = if (coverRadius > 0f) coverRadius else size.minDimension
|
||||||
|
val centerColor = lerp(base, animatedAccent, BLEED_STRENGTH)
|
||||||
|
val midColor = lerp(base, animatedAccent, BLEED_MID_STRENGTH)
|
||||||
|
drawRect(
|
||||||
|
Brush.radialGradient(
|
||||||
|
0f to centerColor,
|
||||||
|
BLEED_MID_STOP to midColor,
|
||||||
|
1f to base,
|
||||||
|
center = c,
|
||||||
|
radius = r,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.systemBarsPadding()
|
||||||
) {
|
) {
|
||||||
// --- Top bar: library + settings ------------------------------------
|
val flexible = maxHeight >= FLEX_MIN_HEIGHT
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
// top = 0: the icon buttons carry their own internal padding, and systemBarsPadding
|
||||||
IconButton(onClick = onOpenQueue) {
|
// already clears the status bar — any extra top padding just pushes them down.
|
||||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
|
val columnModifier =
|
||||||
|
if (flexible) {
|
||||||
|
Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, bottom = 12.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenLibrary) {
|
|
||||||
Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums")
|
Column(modifier = columnModifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
// --- Top bar: disconnect (left) + queue/library/settings (right) ----
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = { showDisconnectConfirm = true }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
IconButton(onClick = onOpenQueue) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onOpenLibrary) {
|
||||||
|
Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
|
||||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Album art -------------------------------------------------------
|
// --- Album art ---------------------------------------------------
|
||||||
ArtImage(
|
val artModel = song?.let { SongArt(it.uri, it.lastModified) }
|
||||||
model = song?.uri?.let { SongArt(it) },
|
if (flexible) {
|
||||||
iconSize = 96.dp,
|
// Fill the leftover height, kept square and capped by width/height/max.
|
||||||
modifier =
|
BoxWithConstraints(
|
||||||
Modifier.padding(vertical = 16.dp).size(240.dp).clip(RoundedCornerShape(16.dp)),
|
modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 12.dp),
|
||||||
)
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
// --- Track metadata --------------------------------------------------
|
val side = minOf(maxWidth, maxHeight, ART_MAX_SIZE)
|
||||||
Text(
|
ArtImage(
|
||||||
text = song?.title ?: song?.uri ?: "Nothing playing",
|
model = artModel,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
iconSize = 96.dp,
|
||||||
textAlign = TextAlign.Center,
|
modifier =
|
||||||
maxLines = 2,
|
Modifier.size(side)
|
||||||
overflow = TextOverflow.Ellipsis,
|
.clip(RoundedCornerShape(16.dp))
|
||||||
)
|
.onGloballyPositioned(::captureCover),
|
||||||
Text(
|
|
||||||
text = song?.artist ?: "—",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = song?.album ?: "",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Descriptive metadata (release year · genre), when the tags are present.
|
|
||||||
val descriptors =
|
|
||||||
listOfNotNull(
|
|
||||||
song?.date?.let(::releaseYear),
|
|
||||||
song?.genre?.takeIf { it.isNotBlank() },
|
|
||||||
)
|
|
||||||
if (descriptors.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = descriptors.joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioPropertyPills(status = status)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
|
|
||||||
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Transport -------------------------------------------------------
|
|
||||||
val playing = status?.state == PlayerState.PLAY
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.SkipPrevious,
|
|
||||||
contentDescription = "Previous",
|
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) {
|
|
||||||
Icon(
|
|
||||||
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
|
||||||
contentDescription = if (playing) "Pause" else "Play",
|
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.SkipNext,
|
|
||||||
contentDescription = "Next",
|
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// CastIndicator(host = serverHost)
|
|
||||||
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Playback options ------------------------------------------------
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = status?.repeat == true,
|
|
||||||
onClick = { vm.setRepeat(status?.repeat != true) },
|
|
||||||
label = { Text("Repeat") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Repeat,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
} else {
|
||||||
FilterChip(
|
ArtImage(
|
||||||
selected = status?.random == true,
|
model = artModel,
|
||||||
onClick = { vm.setRandom(status?.random != true) },
|
iconSize = 96.dp,
|
||||||
label = { Text("Shuffle") },
|
modifier =
|
||||||
leadingIcon = {
|
Modifier.padding(vertical = 16.dp)
|
||||||
Icon(
|
.fillMaxWidth(0.9f)
|
||||||
Icons.Filled.Shuffle,
|
.aspectRatio(1f)
|
||||||
contentDescription = null,
|
.clip(RoundedCornerShape(16.dp))
|
||||||
modifier = Modifier.size(18.dp),
|
.onGloballyPositioned(::captureCover),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
PlaybackDetails(vm = vm, status = status, song = song)
|
||||||
|
|
||||||
TextButton(onClick = vm::disconnect) {
|
|
||||||
Text("Disconnect")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDisconnectConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDisconnectConfirm = false },
|
||||||
|
title = { Text("Disconnect?") },
|
||||||
|
text = { Text("Disconnect from the server and return to the connect screen.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDisconnectConfirm = false
|
||||||
|
vm.disconnect()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything below the album art: track metadata, the seek bar, transport, volume, and the option
|
||||||
|
* chips. Emitted straight into the caller's centered [Column] so it's shared by both the flexible
|
||||||
|
* (no-scroll) and scrolling layouts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackDetails(
|
||||||
|
vm: PlayerViewModel,
|
||||||
|
status: MpdStatus?,
|
||||||
|
song: MpdSong?,
|
||||||
|
) {
|
||||||
|
// --- Track metadata --------------------------------------------------
|
||||||
|
Text(
|
||||||
|
text = song?.title ?: song?.uri ?: "Nothing playing",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = song?.artist ?: "—",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = song?.album ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Descriptive metadata (release year · genre), when the tags are present.
|
||||||
|
val descriptors =
|
||||||
|
listOfNotNull(
|
||||||
|
song?.date?.let(::releaseYear),
|
||||||
|
song?.genre?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
if (descriptors.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = descriptors.joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioPropertyPills(status = status)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// --- Transport (repeat · prev · play/pause · next · shuffle) ---------
|
||||||
|
// Repeat and shuffle are icon-only toggles — their icons are well-known, so no
|
||||||
|
// label is needed. They tint to the primary colour when on, muted when off.
|
||||||
|
val playing = status?.state == PlayerState.PLAY
|
||||||
|
val repeatOn = status?.repeat == true
|
||||||
|
val shuffleOn = status?.random == true
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { vm.setRepeat(!repeatOn) }, modifier = Modifier.size(48.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Repeat,
|
||||||
|
contentDescription = if (repeatOn) "Repeat on" else "Repeat off",
|
||||||
|
tint =
|
||||||
|
if (repeatOn) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.SkipPrevious,
|
||||||
|
contentDescription = "Previous",
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) {
|
||||||
|
Icon(
|
||||||
|
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = if (playing) "Pause" else "Play",
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.SkipNext,
|
||||||
|
contentDescription = "Next",
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { vm.setRandom(!shuffleOn) }, modifier = Modifier.size(48.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Shuffle,
|
||||||
|
contentDescription = if (shuffleOn) "Shuffle on" else "Shuffle off",
|
||||||
|
tint =
|
||||||
|
if (shuffleOn) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,74 +412,18 @@ private fun SeekBar(
|
|||||||
* (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's
|
* (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's
|
||||||
* no format info (e.g. stopped).
|
* no format info (e.g. stopped).
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AudioPropertyPills(status: MpdStatus?) {
|
private fun AudioPropertyPills(status: MpdStatus?) {
|
||||||
val pills = remember(status?.audio, status?.bitrate) { audioPropertyPills(status) }
|
val pills =
|
||||||
if (pills.isEmpty()) return
|
remember(status?.audio, status?.bitrate) {
|
||||||
|
buildList {
|
||||||
FlowRow(
|
addAll(audioFormatPills(status?.audio))
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
status?.bitrate?.takeIf { it > 0 }?.let { add("$it kbps") }
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
}
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
pills.forEach { pill ->
|
|
||||||
Text(
|
|
||||||
text = pill,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier =
|
|
||||||
Modifier.clip(RoundedCornerShape(50))
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
|
||||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
AudioFormatPills(pills = pills, modifier = Modifier.padding(top = 10.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the audio-format pill labels from a status snapshot. MPD's `audio` field is
|
|
||||||
* `samplerate:bits:channels` (e.g. `"44100:16:2"`), where the middle token can be `f` (float) or
|
|
||||||
* `dsd` rather than a bit depth. Only well-formed parts appear.
|
|
||||||
*/
|
|
||||||
private fun audioPropertyPills(status: MpdStatus?): List<String> {
|
|
||||||
status ?: return emptyList()
|
|
||||||
val pills = mutableListOf<String>()
|
|
||||||
|
|
||||||
status.audio
|
|
||||||
?.split(":")
|
|
||||||
?.takeIf { it.size >= 3 }
|
|
||||||
?.let { parts ->
|
|
||||||
parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) }
|
|
||||||
formatSampleFormat(parts[1])?.let { pills.add(it) }
|
|
||||||
formatChannels(parts[2])?.let { pills.add(it) }
|
|
||||||
}
|
|
||||||
status.bitrate?.takeIf { it > 0 }?.let { pills.add("$it kbps") }
|
|
||||||
|
|
||||||
return pills
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatSampleRate(hz: Int): String {
|
|
||||||
val khz = hz / 1000.0
|
|
||||||
val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz)
|
|
||||||
return "$value kHz"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatSampleFormat(token: String): String? =
|
|
||||||
when (token) {
|
|
||||||
"f" -> "Float"
|
|
||||||
"dsd" -> "DSD"
|
|
||||||
else -> token.toIntOrNull()?.let { "$it-bit" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatChannels(token: String): String? =
|
|
||||||
when (token.toIntOrNull()) {
|
|
||||||
null -> null
|
|
||||||
1 -> "Mono"
|
|
||||||
2 -> "Stereo"
|
|
||||||
else -> "$token ch"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
|
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CastIndicator(host: String?) {
|
private fun CastIndicator(host: String?) {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val serverHost = manager.serverHost
|
val serverHost = manager.serverHost
|
||||||
val settings = manager.settings
|
val settings = manager.settings
|
||||||
val albumViewMode = manager.albumViewMode
|
val albumViewMode = manager.albumViewMode
|
||||||
|
val volumeStep = manager.volumeStep
|
||||||
|
val gridColumns = manager.gridColumns
|
||||||
|
|
||||||
val screen: StateFlow<AppScreen> =
|
val screen: StateFlow<AppScreen> =
|
||||||
combine(
|
combine(
|
||||||
@@ -91,6 +93,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode)
|
fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode)
|
||||||
|
|
||||||
|
fun setVolumeStep(step: Int) = manager.setVolumeStep(step)
|
||||||
|
|
||||||
|
fun setGridColumns(columns: Int) = manager.setGridColumns(columns)
|
||||||
|
|
||||||
fun playAlbum(
|
fun playAlbum(
|
||||||
album: String,
|
album: String,
|
||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
@@ -106,6 +112,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
) = manager.playAlbumNext(album, albumArtist)
|
) = manager.playAlbumNext(album, albumArtist)
|
||||||
|
|
||||||
|
fun playTracks(uris: List<String>) = manager.playTracks(uris)
|
||||||
|
|
||||||
fun queueTrack(uri: String) = manager.queueTrack(uri)
|
fun queueTrack(uri: String) = manager.queueTrack(uri)
|
||||||
|
|
||||||
fun playTrackNext(uri: String) = manager.playTrackNext(uri)
|
fun playTrackNext(uri: String) = manager.playTrackNext(uri)
|
||||||
@@ -121,6 +129,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
fun rescanDatabase() = manager.rescanDatabase()
|
fun rescanDatabase() = manager.rescanDatabase()
|
||||||
|
|
||||||
|
fun refreshArtwork() = manager.refreshArtwork()
|
||||||
|
|
||||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
||||||
|
|
||||||
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import ca.ksamad.encore.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
|
import ca.ksamad.encore.playback.AlbumArt
|
||||||
|
import ca.ksamad.encore.playback.MpdArtData
|
||||||
import ca.ksamad.encore.playback.SongArt
|
import ca.ksamad.encore.playback.SongArt
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -220,6 +222,17 @@ fun QueueScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art model for a queue row. Keying by album — not the per-track URI — means every track of the
|
||||||
|
* same album resolves to one cache entry, so a 12-track album is fetched from the server once
|
||||||
|
* instead of twelve times, and reuses whatever the album library already cached. Falls back to the
|
||||||
|
* track's own art only for entries with no album tag (e.g. a loose file or a stream).
|
||||||
|
*/
|
||||||
|
private fun queueArt(song: MpdSong): MpdArtData =
|
||||||
|
song.album
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { AlbumArt(it, song.albumArtist) } ?: SongArt(song.uri, song.lastModified)
|
||||||
|
|
||||||
/** A single queue entry — art, title/artist, and a duration or now-playing badge. */
|
/** A single queue entry — art, title/artist, and a duration or now-playing badge. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRow(
|
private fun QueueRow(
|
||||||
@@ -237,7 +250,7 @@ private fun QueueRow(
|
|||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
ArtImage(
|
ArtImage(
|
||||||
model = SongArt(song.uri),
|
model = queueArt(song),
|
||||||
iconSize = 20.dp,
|
iconSize = 20.dp,
|
||||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)),
|
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ package ca.ksamad.encore.ui
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Remove
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -43,10 +48,13 @@ import androidx.compose.runtime.produceState
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import ca.ksamad.encore.data.AlbumViewMode
|
import ca.ksamad.encore.data.AlbumViewMode
|
||||||
|
import ca.ksamad.encore.data.SettingsRepository
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -141,6 +149,35 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// How far the hardware volume keys move the server volume per press.
|
||||||
|
val volumeStep by vm.volumeStep.collectAsStateWithLifecycle()
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Volume step") },
|
||||||
|
supportingContent = { Text("How much each volume-key press changes the volume") },
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { vm.setVolumeStep(volumeStep - 1) },
|
||||||
|
enabled = volumeStep > SettingsRepository.MIN_VOLUME_STEP,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Remove, contentDescription = "Decrease volume step")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"$volumeStep%",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.width(40.dp),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { vm.setVolumeStep(volumeStep + 1) },
|
||||||
|
enabled = volumeStep < SettingsRepository.MAX_VOLUME_STEP,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = "Increase volume step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Library",
|
"Library",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
@@ -176,6 +213,35 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Column count only matters for the grid layout, so only offer it there.
|
||||||
|
if (albumViewMode == AlbumViewMode.Grid) {
|
||||||
|
val gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
|
||||||
|
val columnOptions =
|
||||||
|
SettingsRepository.MIN_GRID_COLUMNS..SettingsRepository.MAX_GRID_COLUMNS
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Grid columns") },
|
||||||
|
supportingContent = { Text("Number of albums per row") },
|
||||||
|
trailingContent = {
|
||||||
|
SingleChoiceSegmentedButtonRow {
|
||||||
|
columnOptions.forEachIndexed { index, columns ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = gridColumns == columns,
|
||||||
|
onClick = { vm.setGridColumns(columns) },
|
||||||
|
shape =
|
||||||
|
SegmentedButtonDefaults.itemShape(
|
||||||
|
index = index,
|
||||||
|
count = columnOptions.count(),
|
||||||
|
),
|
||||||
|
icon = {},
|
||||||
|
) {
|
||||||
|
Text(columns.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Ask the server to rescan its music sources. Tap runs a normal (mtime-based)
|
// Ask the server to rescan its music sources. Tap runs a normal (mtime-based)
|
||||||
// update; the overflow offers a full rescan that also re-reads unchanged files.
|
// update; the overflow offers a full rescan that also re-reads unchanged files.
|
||||||
// While a scan runs, `status.updating` drives the "Refreshing…" state.
|
// While a scan runs, `status.updating` drives the "Refreshing…" state.
|
||||||
@@ -219,6 +285,23 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Drop Encore's cached covers so corrected art on the server shows up. Most
|
||||||
|
// re-tagged/re-imported tracks refresh on their own (their mtime changes the
|
||||||
|
// art's cache key); this is for the rest — album covers and bare cover.jpg
|
||||||
|
// swaps that don't bump a track's mtime.
|
||||||
|
ListItem(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
vm.refreshArtwork()
|
||||||
|
flash("Artwork cache cleared — covers will reload")
|
||||||
|
},
|
||||||
|
headlineContent = { Text("Refresh artwork") },
|
||||||
|
supportingContent = { Text("Reload cover art from the server") },
|
||||||
|
trailingContent = {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Server statistics",
|
"Server statistics",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
|||||||
@@ -1,89 +1,122 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SwipeToDismissBox
|
|
||||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
/** Fraction of a row's width a swipe must cross before the action fires (vs. the 56.dp default). */
|
import kotlin.math.roundToInt
|
||||||
private const val SWIPE_TRIGGER_FRACTION = 0.5f
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue]
|
* A swipe only fires when the finger is still moving horizontally faster than this on release — a
|
||||||
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
|
* deliberate flick. A slow drag (e.g. the small sideways drift while scrolling the list up and down)
|
||||||
|
* settles back without triggering, which is the whole point.
|
||||||
|
*/
|
||||||
|
private val SWIPE_VELOCITY_THRESHOLD = 450.dp // per second
|
||||||
|
|
||||||
|
/** How far a row can be dragged sideways (enough to show the reveal, then it clamps). */
|
||||||
|
private val SWIPE_MAX_REVEAL = 96.dp
|
||||||
|
|
||||||
|
/** Minimum sideways travel before a flick counts, so a fast stationary twitch can't trigger it. */
|
||||||
|
private val SWIPE_MIN_DISTANCE = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps [content] in the app's shared "queue swipe" affordance: flick right to [onAddToQueue]
|
||||||
|
* (append), flick left to [onPlayNext] (insert after the current track). Used for both album rows
|
||||||
* and individual tracks so the gesture and its meaning stay identical wherever it appears.
|
* and individual tracks so the gesture and its meaning stay identical wherever it appears.
|
||||||
*
|
*
|
||||||
* Both are one-shot actions, not deletions, so the row always springs back and stays in the list —
|
* Unlike a dismiss-style swipe, committing is gated on **release velocity**, not distance — so
|
||||||
* [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once settled.
|
* accidental sideways movement while scrolling vertically slides a little and springs back instead
|
||||||
|
* of firing. [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once
|
||||||
|
* settled at rest.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QueueSwipeRow(
|
fun QueueSwipeRow(
|
||||||
onAddToQueue: () -> Unit,
|
onAddToQueue: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state =
|
val scope = rememberCoroutineScope()
|
||||||
rememberSwipeToDismissBoxState(
|
val offsetX = remember { Animatable(0f) }
|
||||||
// Require the swipe to cross half the row's width before it counts, rather
|
|
||||||
// than the default fixed 56.dp — a small drag or flick was triggering the
|
|
||||||
// queue/play-next action too easily.
|
|
||||||
positionalThreshold = { totalDistance -> totalDistance * SWIPE_TRIGGER_FRACTION },
|
|
||||||
confirmValueChange = { target ->
|
|
||||||
when (target) {
|
|
||||||
SwipeToDismissBoxValue.StartToEnd -> {
|
|
||||||
onAddToQueue()
|
|
||||||
}
|
|
||||||
|
|
||||||
SwipeToDismissBoxValue.EndToStart -> {
|
val density = LocalDensity.current
|
||||||
onPlayNext()
|
val velocityThresholdPx = with(density) { SWIPE_VELOCITY_THRESHOLD.toPx() }
|
||||||
}
|
val maxRevealPx = with(density) { SWIPE_MAX_REVEAL.toPx() }
|
||||||
|
val minDistancePx = with(density) { SWIPE_MIN_DISTANCE.toPx() }
|
||||||
|
|
||||||
SwipeToDismissBoxValue.Settled -> {}
|
Box(
|
||||||
}
|
modifier =
|
||||||
false // Never settle to dismissed — snap back and keep the row.
|
Modifier.pointerInput(Unit) {
|
||||||
|
val tracker = VelocityTracker()
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { tracker.resetTracking() },
|
||||||
|
onHorizontalDrag = { change, dragAmount ->
|
||||||
|
tracker.addPosition(change.uptimeMillis, change.position)
|
||||||
|
val target = (offsetX.value + dragAmount).coerceIn(-maxRevealPx, maxRevealPx)
|
||||||
|
scope.launch { offsetX.snapTo(target) }
|
||||||
|
change.consume()
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val velocity = tracker.calculateVelocity().x
|
||||||
|
val offset = offsetX.value
|
||||||
|
// A deliberate flick: fast enough, far enough, and the flick and the
|
||||||
|
// drag point the same way (so a bounce-back release doesn't count).
|
||||||
|
val committed =
|
||||||
|
abs(velocity) >= velocityThresholdPx &&
|
||||||
|
abs(offset) >= minDistancePx &&
|
||||||
|
(velocity > 0f) == (offset > 0f)
|
||||||
|
if (committed) {
|
||||||
|
if (offset > 0f) onAddToQueue() else onPlayNext()
|
||||||
|
}
|
||||||
|
scope.launch { offsetX.animateTo(0f) }
|
||||||
|
},
|
||||||
|
onDragCancel = { scope.launch { offsetX.animateTo(0f) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
SwipeToDismissBox(
|
|
||||||
state = state,
|
|
||||||
backgroundContent = { SwipeActionBackground(state.dismissDirection) },
|
|
||||||
) {
|
) {
|
||||||
content()
|
SwipeActionBackground(offset = offsetX.value, modifier = Modifier.matchParentSize())
|
||||||
|
Box(modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (swipe
|
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (drag
|
||||||
* right) and a "Play next" hint on the trailing edge (swipe left). Renders empty while the row is
|
* right, [offset] > 0) and a "Play next" hint on the trailing edge (drag left, [offset] < 0).
|
||||||
* settled.
|
* Renders empty while the row is settled at rest.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
private fun SwipeActionBackground(
|
||||||
if (direction == SwipeToDismissBoxValue.Settled) {
|
offset: Float,
|
||||||
Box(Modifier.fillMaxSize())
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (offset == 0f) {
|
||||||
|
Box(modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val queueing = direction == SwipeToDismissBoxValue.StartToEnd
|
val queueing = offset > 0f
|
||||||
val container =
|
val container =
|
||||||
if (queueing) {
|
if (queueing) {
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
@@ -100,7 +133,7 @@ private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
|||||||
val label = if (queueing) "Add to queue" else "Play next"
|
val label = if (queueing) "Add to queue" else "Play next"
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(container).padding(horizontal = 24.dp),
|
modifier.background(container).padding(horizontal = 24.dp),
|
||||||
contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd,
|
contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ datastore = "1.1.1" # Jetpack DataStore (persisting connection sett
|
|||||||
media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume
|
media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume
|
||||||
coil = "3.0.4" # Coil: image loading + memory/disk caching for album art
|
coil = "3.0.4" # Coil: image loading + memory/disk caching for album art
|
||||||
# (pinned to a 3.x that targets compileSdk 35; 3.5 needs 36)
|
# (pinned to a 3.x that targets compileSdk 35; 3.5 needs 36)
|
||||||
|
palette = "1.0.0" # AndroidX Palette: extract cover-art colours for the bleed effect
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -32,6 +33,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
|||||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
Reference in New Issue
Block a user