Compare commits

...
2 Commits
Author SHA1 Message Date
kas2020 0a2ae6adf8 feat: add colour gradients 2026-08-08 17:21:17 -04:00
kas2020 163fa6c87e fix: multiple small UI/UX tweaks for improved functionality. 2026-08-08 17:21:17 -04:00
21 changed files with 930 additions and 372 deletions
+1
View File
@@ -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) {
+2
View File
@@ -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]