Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2ae6adf8
|
||
|
|
163fa6c87e
|
||
|
|
62383accf7
|
@@ -1,5 +1,7 @@
|
||||
# Encore
|
||||
|
||||
<img src="fastlane/metadata/android/en-US/images/icon.png" width="120" align="right" alt="Encore app icon">
|
||||
|
||||
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
||||
tested with **Nix**.
|
||||
|
||||
@@ -7,6 +9,12 @@ Jetpack Compose *is* the modern Android UI framework — declarative Kotlin UI t
|
||||
replaced the old XML layouts. Material 3 is Google's current design system, wired up here
|
||||
with dynamic (wallpaper-based) color on Android 12+.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Now Playing | Library | Queue | Settings |
|
||||
|:-----------:|:-------:|:-----:|:--------:|
|
||||
| <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="180" alt="Now Playing"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="180" alt="Album grid"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="180" alt="Queue"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="180" alt="Settings"> |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
||||
@@ -137,6 +137,7 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.androidx.palette)
|
||||
|
||||
// The Compose BOM aligns every Compose artifact to one tested version set,
|
||||
// so the individual Compose deps below are declared without versions.
|
||||
|
||||
@@ -4,40 +4,32 @@ import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import ca.ksamad.encore.playback.VolumeKeyDispatcher
|
||||
import ca.ksamad.encore.ui.EncoreApp
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val requestNotificationPermission =
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
maybeRequestNotificationPermission()
|
||||
setContent {
|
||||
EncoreTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
// No inset padding here: each screen's own Scaffold/TopAppBar (or, for the
|
||||
// plain-Column screens, their own systemBarsPadding) applies the system-bar
|
||||
// insets. Doing it at the root too would double the status-bar gap.
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
EncoreApp()
|
||||
@@ -45,10 +37,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
||||
volumeKeys.dispatch(event) || super.dispatchKeyEvent(event)
|
||||
|
||||
// The media notification needs POST_NOTIFICATIONS on Android 13+. Without it
|
||||
// the foreground service still runs and cast volume still works, but the
|
||||
|
||||
@@ -26,6 +26,8 @@ class SettingsRepository(private val context: Context) {
|
||||
val PORT = intPreferencesKey("port")
|
||||
val PASSWORD = stringPreferencesKey("password")
|
||||
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. */
|
||||
@@ -36,6 +38,36 @@ class SettingsRepository(private val context: Context) {
|
||||
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).
|
||||
* 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() {
|
||||
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]. */
|
||||
suspend fun disconnect() {
|
||||
shuttingDown = true
|
||||
stopBackgroundJobs()
|
||||
// Closing the idle socket unblocks the loop's parked `idle` read.
|
||||
// Close the sockets *before* joining the loops. The idle loop is parked in a blocking
|
||||
// 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) {
|
||||
idleConn?.close()
|
||||
commandConn?.close()
|
||||
}
|
||||
stopBackgroundJobs()
|
||||
closeArtConnections()
|
||||
idleConn = null
|
||||
commandConn = null
|
||||
@@ -237,6 +240,21 @@ class MpdClient(
|
||||
/** Trigger a full re-read of the database, including files with an unchanged mtime. */
|
||||
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. */
|
||||
suspend fun playAlbum(
|
||||
album: String,
|
||||
@@ -364,8 +382,13 @@ class MpdClient(
|
||||
// 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
|
||||
// (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
|
||||
// on any transport error (a stale/timed-out one just gets reopened).
|
||||
// to ART_POOL_SIZE (bounded by the semaphore), and reused when idle.
|
||||
//
|
||||
// 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 artPoolLock = Any()
|
||||
@@ -373,7 +396,29 @@ class MpdClient(
|
||||
|
||||
private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
|
||||
artSemaphore.withPermit {
|
||||
val conn = borrowArtConnection()
|
||||
// First try a pooled connection, if any. A transport failure here means
|
||||
// it went stale — discard it (runArt already closed it) and fall through
|
||||
// to a fresh one. A server ACK is a real result and is not retried.
|
||||
borrowPooledArtConnection()?.let { pooled ->
|
||||
try {
|
||||
return@withPermit runArt(pooled, block)
|
||||
} catch (e: MpdAckException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
// stale pooled connection; retry on a fresh one below
|
||||
}
|
||||
}
|
||||
runArt(openArtConnection(), block)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -385,21 +430,20 @@ class MpdClient(
|
||||
runCatching { conn.close() } // discard a broken connection
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun borrowArtConnection(): MpdConnection {
|
||||
/** Pop an idle pooled art connection, or null if the pool is empty. */
|
||||
private fun borrowPooledArtConnection(): MpdConnection? =
|
||||
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
return withContext(ioDispatcher) {
|
||||
|
||||
/** Open, authenticate, and prime a brand-new art connection. */
|
||||
private suspend fun openArtConnection(): MpdConnection =
|
||||
withContext(ioDispatcher) {
|
||||
val h = host ?: throw MpdConnectionException("not connected")
|
||||
val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
|
||||
password?.let { conn.execute(MpdCommands.password(it)) }
|
||||
runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
|
||||
conn
|
||||
}
|
||||
}
|
||||
|
||||
private fun returnArtConnection(conn: MpdConnection) {
|
||||
val kept =
|
||||
@@ -483,8 +527,11 @@ class MpdClient(
|
||||
}
|
||||
|
||||
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()
|
||||
stopBackgroundJobs()
|
||||
_connectionState.value = MpdConnectionState.Connecting
|
||||
var attempt = 0
|
||||
while (scope.isActive && !shuttingDown) {
|
||||
|
||||
@@ -18,6 +18,8 @@ data class MpdSong(
|
||||
val disc: String?,
|
||||
val date: 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 pos: Int?, // queue position, present in queue listings
|
||||
val id: Int?, // stable queue id, present in queue listings
|
||||
@@ -39,6 +41,8 @@ data class MpdSong(
|
||||
disc = values["Disc"],
|
||||
date = values["Date"],
|
||||
genre = values["Genre"],
|
||||
lastModified = values["Last-Modified"],
|
||||
format = values["Format"],
|
||||
duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(),
|
||||
pos = values["Pos"]?.toIntOrNull(),
|
||||
id = values["Id"]?.toIntOrNull(),
|
||||
|
||||
@@ -41,10 +41,16 @@ object ArtImageLoader {
|
||||
}
|
||||
.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 =
|
||||
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}"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,24 @@ package ca.ksamad.encore.playback
|
||||
*/
|
||||
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(
|
||||
val album: 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.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import coil3.SingletonImageLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -56,6 +57,30 @@ class MpdConnectionManager(context: Context) {
|
||||
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.
|
||||
private val _bootstrapping = MutableStateFlow(true)
|
||||
val bootstrapping: StateFlow<Boolean> = _bootstrapping
|
||||
@@ -176,6 +201,9 @@ class MpdConnectionManager(context: Context) {
|
||||
albumArtist: String?,
|
||||
) = 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. */
|
||||
fun queueTrack(uri: String) = fire { queueTrack(uri) }
|
||||
|
||||
@@ -218,6 +246,19 @@ class MpdConnectionManager(context: Context) {
|
||||
suspend fun loadQueue(): List<MpdSong> =
|
||||
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). */
|
||||
suspend fun fetchArt(data: MpdArtData): ByteArray? =
|
||||
runCatching {
|
||||
@@ -228,19 +269,11 @@ class MpdConnectionManager(context: Context) {
|
||||
}
|
||||
.getOrNull()
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
/** Relative volume change (from the media session's remote VolumeProvider), accumulating. */
|
||||
fun nudgeVolume(up: Boolean) {
|
||||
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
|
||||
setVolume(next)
|
||||
}
|
||||
@@ -250,7 +283,6 @@ class MpdConnectionManager(context: Context) {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VOLUME_STEP = 5
|
||||
const val VOLUME_THROTTLE_MS = 120L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,8 @@ class PlaybackService : Service() {
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { 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) {
|
||||
session.setMetadata(
|
||||
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 =
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.
|
||||
* Tapping Play replaces the queue and starts the album (leaving this screen); swiping a track
|
||||
* queues it (right) or plays it next (left), with the same semantics as swiping an album in the
|
||||
* library.
|
||||
* Tapping Play replaces the queue and starts the album (leaving this screen); tapping a track does
|
||||
* the same but starts the album from that track onward. Swiping a track queues it (right) or plays
|
||||
* it next (left), with the same semantics as swiping an album in the library.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -66,6 +68,10 @@ fun AlbumDetailScreen(
|
||||
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 scope = rememberCoroutineScope()
|
||||
|
||||
@@ -91,6 +97,7 @@ fun AlbumDetailScreen(
|
||||
item {
|
||||
AlbumDetailHeader(
|
||||
album = album,
|
||||
format = albumFormat,
|
||||
onPlay = {
|
||||
vm.playAlbum(album.name, album.albumArtist)
|
||||
onPlay()
|
||||
@@ -102,11 +109,21 @@ fun AlbumDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// One track row, wired to its swipe actions. Reused whether the list is
|
||||
// flat or split into disc groups.
|
||||
// One track row, wired to its tap + swipe actions. Reused whether the list
|
||||
// is flat or split into disc groups.
|
||||
val trackItem: @Composable (MpdSong) -> Unit = { track ->
|
||||
TrackRow(
|
||||
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 = {
|
||||
vm.queueTrack(track.uri)
|
||||
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
|
||||
private fun AlbumDetailHeader(
|
||||
album: MpdAlbum,
|
||||
format: String?,
|
||||
onPlay: () -> Unit,
|
||||
onQueue: () -> Unit,
|
||||
) {
|
||||
@@ -177,7 +195,7 @@ private fun AlbumDetailHeader(
|
||||
ArtImage(
|
||||
model = AlbumArt(album.name, album.albumArtist),
|
||||
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))
|
||||
Text(
|
||||
@@ -197,6 +215,7 @@ private fun AlbumDetailHeader(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
AudioFormatPills(pills = audioFormatPills(format), modifier = Modifier.padding(top = 10.dp))
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Row(
|
||||
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
|
||||
* as the album 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).
|
||||
* A single track row. Tapping plays the album from this track onward (replacing the queue); swiping
|
||||
* right queues just this track and swiping left plays it next — the same gestures as the album
|
||||
* 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
|
||||
private fun TrackRow(
|
||||
track: MpdSong,
|
||||
onPlay: () -> Unit,
|
||||
onQueue: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
) {
|
||||
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onPlay),
|
||||
leadingContent = {
|
||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
@@ -266,3 +288,16 @@ private fun trackNumberOf(track: MpdSong): Int? =
|
||||
/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */
|
||||
private fun discNumberOf(track: MpdSong): Int =
|
||||
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.size
|
||||
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.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -69,10 +71,15 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun AlbumsScreen(
|
||||
vm: PlayerViewModel,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
listState: LazyListState,
|
||||
gridState: LazyGridState,
|
||||
onBack: () -> Unit,
|
||||
onOpenAlbum: (MpdAlbum) -> Unit,
|
||||
) {
|
||||
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
||||
val gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
|
||||
|
||||
// null = still loading.
|
||||
val albums by
|
||||
@@ -84,9 +91,10 @@ fun AlbumsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Client-side search over the already-loaded index (album title + album artist).
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
// The search text is hoisted (owned by EncoreApp) so it survives drilling into
|
||||
// an album and coming back. Whether the search bar is *open* is local, but it
|
||||
// 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).
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -128,7 +136,7 @@ fun AlbumsScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (searching) {
|
||||
AlbumSearchField(query = query, onQueryChange = { query = it })
|
||||
AlbumSearchField(query = query, onQueryChange = onQueryChange)
|
||||
} else {
|
||||
Text(filtered?.let { "Albums (${it.size})" } ?: "Albums")
|
||||
}
|
||||
@@ -138,7 +146,7 @@ fun AlbumsScreen(
|
||||
onClick = {
|
||||
if (searching) {
|
||||
searching = false
|
||||
query = ""
|
||||
onQueryChange("")
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
@@ -149,7 +157,7 @@ fun AlbumsScreen(
|
||||
},
|
||||
actions = {
|
||||
if (searching) {
|
||||
IconButton(onClick = { query = "" }, enabled = query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }, enabled = query.isNotEmpty()) {
|
||||
Icon(Icons.Filled.Clear, contentDescription = "Clear search")
|
||||
}
|
||||
} else if (current != null) {
|
||||
@@ -187,7 +195,8 @@ fun AlbumsScreen(
|
||||
|
||||
viewMode == AlbumViewMode.Grid -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
columns = GridCells.Fixed(gridColumns),
|
||||
state = gridState,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -200,7 +209,7 @@ fun AlbumsScreen(
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
LazyColumn(state = listState, modifier = Modifier.padding(innerPadding)) {
|
||||
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
||||
SwipeableAlbumRow(
|
||||
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;
|
||||
* the surrounding [TopAppBar] handles clearing/closing.
|
||||
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard
|
||||
* 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)
|
||||
@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.height
|
||||
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.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
@@ -63,6 +65,14 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
||||
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
||||
// When set (within the Albums overlay), the album detail screen is shown.
|
||||
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
|
||||
// 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) {
|
||||
is AppScreen.Loading -> {
|
||||
LoadingScreen()
|
||||
@@ -115,6 +132,10 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
||||
} else {
|
||||
AlbumsScreen(
|
||||
vm,
|
||||
query = albumSearch,
|
||||
onQueryChange = { albumSearch = it },
|
||||
listState = albumListState,
|
||||
gridState = albumGridState,
|
||||
onBack = { overlay = PlayerOverlay.None },
|
||||
onOpenAlbum = { detailAlbum = it },
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.VolumeUp
|
||||
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.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -45,15 +48,53 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.PlayerState
|
||||
import ca.ksamad.encore.playback.SongArt
|
||||
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
|
||||
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
|
||||
@@ -68,14 +109,70 @@ fun NowPlayingScreen(
|
||||
) {
|
||||
val status by vm.status.collectAsStateWithLifecycle()
|
||||
val song by vm.currentSong.collectAsStateWithLifecycle()
|
||||
val serverHost by vm.serverHost.collectAsStateWithLifecycle()
|
||||
var showDisconnectConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
// --- Album-art colour bleed ------------------------------------------------
|
||||
// Pull the cover's dominant colour and radiate it from the cover's on-screen position so the
|
||||
// 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 ------------------------------------
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
val flexible = maxHeight >= FLEX_MIN_HEIGHT
|
||||
// top = 0: the icon buttons carry their own internal padding, and systemBarsPadding
|
||||
// already clears the status bar — any extra top padding just pushes them down.
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -87,14 +184,74 @@ fun NowPlayingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album art -------------------------------------------------------
|
||||
// --- Album art ---------------------------------------------------
|
||||
val artModel = song?.let { SongArt(it.uri, it.lastModified) }
|
||||
if (flexible) {
|
||||
// Fill the leftover height, kept square and capped by width/height/max.
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val side = minOf(maxWidth, maxHeight, ART_MAX_SIZE)
|
||||
ArtImage(
|
||||
model = song?.uri?.let { SongArt(it) },
|
||||
model = artModel,
|
||||
iconSize = 96.dp,
|
||||
modifier =
|
||||
Modifier.padding(vertical = 16.dp).size(240.dp).clip(RoundedCornerShape(16.dp)),
|
||||
Modifier.size(side)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.onGloballyPositioned(::captureCover),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ArtImage(
|
||||
model = artModel,
|
||||
iconSize = 96.dp,
|
||||
modifier =
|
||||
Modifier.padding(vertical = 16.dp)
|
||||
.fillMaxWidth(0.9f)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.onGloballyPositioned(::captureCover),
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDetails(vm = vm, status = status, song = song)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -138,18 +295,33 @@ fun NowPlayingScreen(
|
||||
|
||||
AudioPropertyPills(status = status)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// --- Transport -------------------------------------------------------
|
||||
// --- 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.spacedBy(16.dp),
|
||||
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,
|
||||
@@ -171,49 +343,21 @@ fun NowPlayingScreen(
|
||||
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),
|
||||
)
|
||||
},
|
||||
)
|
||||
FilterChip(
|
||||
selected = status?.random == true,
|
||||
onClick = { vm.setRandom(status?.random != true) },
|
||||
label = { Text("Shuffle") },
|
||||
leadingIcon = {
|
||||
IconButton(onClick = { vm.setRandom(!shuffleOn) }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Shuffle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
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(24.dp))
|
||||
|
||||
TextButton(onClick = vm::disconnect) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,72 +412,16 @@ private fun SeekBar(
|
||||
* (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's
|
||||
* no format info (e.g. stopped).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun AudioPropertyPills(status: MpdStatus?) {
|
||||
val pills = remember(status?.audio, status?.bitrate) { audioPropertyPills(status) }
|
||||
if (pills.isEmpty()) return
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
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),
|
||||
)
|
||||
val pills =
|
||||
remember(status?.audio, status?.bitrate) {
|
||||
buildList {
|
||||
addAll(audioFormatPills(status?.audio))
|
||||
status?.bitrate?.takeIf { it > 0 }?.let { add("$it kbps") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
AudioFormatPills(pills = pills, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
|
||||
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
|
||||
|
||||
@@ -41,6 +41,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
val serverHost = manager.serverHost
|
||||
val settings = manager.settings
|
||||
val albumViewMode = manager.albumViewMode
|
||||
val volumeStep = manager.volumeStep
|
||||
val gridColumns = manager.gridColumns
|
||||
|
||||
val screen: StateFlow<AppScreen> =
|
||||
combine(
|
||||
@@ -91,6 +93,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode)
|
||||
|
||||
fun setVolumeStep(step: Int) = manager.setVolumeStep(step)
|
||||
|
||||
fun setGridColumns(columns: Int) = manager.setGridColumns(columns)
|
||||
|
||||
fun playAlbum(
|
||||
album: String,
|
||||
albumArtist: String?,
|
||||
@@ -106,6 +112,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
albumArtist: String?,
|
||||
) = manager.playAlbumNext(album, albumArtist)
|
||||
|
||||
fun playTracks(uris: List<String>) = manager.playTracks(uris)
|
||||
|
||||
fun queueTrack(uri: String) = manager.queueTrack(uri)
|
||||
|
||||
fun playTrackNext(uri: String) = manager.playTrackNext(uri)
|
||||
@@ -121,6 +129,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
fun rescanDatabase() = manager.rescanDatabase()
|
||||
|
||||
fun refreshArtwork() = manager.refreshArtwork()
|
||||
|
||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
||||
|
||||
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
||||
|
||||
@@ -50,6 +50,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
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 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. */
|
||||
@Composable
|
||||
private fun QueueRow(
|
||||
@@ -237,7 +250,7 @@ private fun QueueRow(
|
||||
},
|
||||
leadingContent = {
|
||||
ArtImage(
|
||||
model = SongArt(song.uri),
|
||||
model = queueArt(song),
|
||||
iconSize = 20.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.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Refresh
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -43,10 +48,13 @@ import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.encore.data.AlbumViewMode
|
||||
import ca.ksamad.encore.data.SettingsRepository
|
||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import java.text.SimpleDateFormat
|
||||
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(
|
||||
"Library",
|
||||
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)
|
||||
// update; the overflow offers a full rescan that also re-reads unchanged files.
|
||||
// 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(
|
||||
"Server statistics",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
|
||||
@@ -1,89 +1,122 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
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
|
||||
|
||||
/** Fraction of a row's width a swipe must cross before the action fires (vs. the 56.dp default). */
|
||||
private const val SWIPE_TRIGGER_FRACTION = 0.5f
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue]
|
||||
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
|
||||
* A swipe only fires when the finger is still moving horizontally faster than this on release — a
|
||||
* 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.
|
||||
*
|
||||
* Both are one-shot actions, not deletions, so the row always springs back and stays in the list —
|
||||
* [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once settled.
|
||||
* Unlike a dismiss-style swipe, committing is gated on **release velocity**, not distance — so
|
||||
* 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
|
||||
fun QueueSwipeRow(
|
||||
onAddToQueue: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val state =
|
||||
rememberSwipeToDismissBoxState(
|
||||
// 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()
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val offsetX = remember { Animatable(0f) }
|
||||
|
||||
SwipeToDismissBoxValue.EndToStart -> {
|
||||
onPlayNext()
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
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 -> {}
|
||||
}
|
||||
false // Never settle to dismissed — snap back and keep the row.
|
||||
Box(
|
||||
modifier =
|
||||
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
|
||||
* right) and a "Play next" hint on the trailing edge (swipe left). Renders empty while the row is
|
||||
* settled.
|
||||
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (drag
|
||||
* right, [offset] > 0) and a "Play next" hint on the trailing edge (drag left, [offset] < 0).
|
||||
* Renders empty while the row is settled at rest.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
||||
if (direction == SwipeToDismissBoxValue.Settled) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
private fun SwipeActionBackground(
|
||||
offset: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (offset == 0f) {
|
||||
Box(modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val queueing = direction == SwipeToDismissBoxValue.StartToEnd
|
||||
val queueing = offset > 0f
|
||||
val container =
|
||||
if (queueing) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
@@ -100,7 +133,7 @@ private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
||||
val label = if (queueing) "Add to queue" else "Play next"
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(container).padding(horizontal = 24.dp),
|
||||
modifier.background(container).padding(horizontal = 24.dp),
|
||||
contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
|
Before Width: | Height: | Size: 629 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 452 KiB |
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 46 KiB |
@@ -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
|
||||
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)
|
||||
palette = "1.0.0" # AndroidX Palette: extract cover-art colours for the bleed effect
|
||||
junit = "4.13.2"
|
||||
|
||||
[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-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
||||
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" }
|
||||
|
||||
[plugins]
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||