Compare commits

..
11 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
kas2020 62383accf7 chore: improve image sizing. 2026-07-31 00:22:43 -04:00
kas2020 22d570e191 feat: add metadata for F-Droid. 2026-07-30 23:15:53 -04:00
kas2020 330de1eda9 feat: add icon assets 2026-07-30 23:15:53 -04:00
kas2020 bbc92278f4 feat: add keystore properties pipeline 2026-07-30 23:15:53 -04:00
kas2020 d9b665cdca feat: add server update and scan functionality 2026-07-30 23:15:53 -04:00
kas2020 3d57fca003 license: add 2026-07-30 23:15:53 -04:00
kas2020 de13d3b36b feat: minor polishing improvements.
This CL exposes a password setting for mpd servers that require it, and also fixes some swiping sensitivity issues.
2026-07-30 23:15:53 -04:00
kas2020 665df77fc4 feat: add a grid view 2026-07-30 23:15:53 -04:00
kas2020 3abd2c4c96 fix: back button should take you to the previous screen instead of quitting the app. 2026-07-30 23:15:53 -04:00
67 changed files with 1589 additions and 469 deletions
+5
View File
@@ -7,6 +7,11 @@ build/
*.aab *.aab
local.properties local.properties
# Release signing — NEVER commit the key or its passwords
keystore.properties
*.jks
*.keystore
# Nix # Nix
result result
result-* result-*
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Karim Abdul-Samad
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+12
View File
@@ -1,5 +1,7 @@
# Encore # 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 A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
tested with **Nix**. 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 replaced the old XML layouts. Material 3 is Google's current design system, wired up here
with dynamic (wallpaper-based) color on Android 12+. 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 ## Layout
``` ```
@@ -101,6 +109,10 @@ downloads through a man-in-the-middle proxy and storing their hashes — see
real Android device/DAP over USB (and the NixOS `adb` notes). real Android device/DAP over USB (and the NixOS `adb` notes).
- [`docs/TODO.md`](docs/TODO.md) — near-term roadmap / known bugs. - [`docs/TODO.md`](docs/TODO.md) — near-term roadmap / known bugs.
## License
Encore is licensed under the [Apache License 2.0](LICENSE).
## Version notes ## Version notes
Pinned in `gradle/libs.versions.toml`: AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01, Pinned in `gradle/libs.versions.toml`: AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01,
+29
View File
@@ -1,9 +1,21 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
} }
// Release signing is loaded from keystore.properties, which exists only on a machine
// that owns the signing key (never committed — see .gitignore). F-Droid and CI have no
// such file and build unsigned; that unsigned output is exactly what F-Droid's
// reproducible-build check compares against before re-applying our signature.
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties =
Properties().apply {
if (keystorePropertiesFile.exists()) keystorePropertiesFile.inputStream().use { load(it) }
}
android { android {
namespace = "ca.ksamad.encore" namespace = "ca.ksamad.encore"
compileSdk = 35 compileSdk = 35
@@ -21,8 +33,24 @@ android {
versionName = "0.1.0" versionName = "0.1.0"
} }
signingConfigs {
// Present only when the developer keystore is configured; absent on F-Droid/CI.
if (keystorePropertiesFile.exists()) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes { buildTypes {
release { release {
// Sign with the release key when configured; otherwise stay unsigned so
// F-Droid can build/verify reproducibly and re-apply our signature.
signingConfig = signingConfigs.findByName("release")
// R8 code shrinking + resource shrinking. Critical with // R8 code shrinking + resource shrinking. Critical with
// material-icons-extended: it bundles thousands of vector icons, and // material-icons-extended: it bundles thousands of vector icons, and
// R8 strips everything we don't reference (debug builds can't shrink, // R8 strips everything we don't reference (debug builds can't shrink,
@@ -109,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
View File
@@ -12,7 +12,11 @@
<application <application
android:name=".EncoreApplication" android:name=".EncoreApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Encore"> android:theme="@style/Theme.Encore">
<activity <activity
@@ -4,40 +4,32 @@ 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
// plain-Column screens, their own systemBarsPadding) applies the system-bar
// insets. Doing it at the root too would double the status-bar gap.
Surface( Surface(
modifier = Modifier.fillMaxSize().padding(innerPadding), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
EncoreApp() 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 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
@@ -0,0 +1,16 @@
package ca.ksamad.encore.data
/** How the albums library is laid out: a compact [List] or a two-column [Grid] of cover art. */
enum class AlbumViewMode {
List,
Grid,
;
companion object {
/** Default when nothing is persisted yet. */
val DEFAULT = List
/** Parse a persisted name back to a mode, tolerating unknown/legacy values. */
fun fromName(name: String?): AlbumViewMode = entries.find { it.name == name } ?: DEFAULT
}
}
@@ -4,6 +4,8 @@ package ca.ksamad.encore.data
data class ConnectionSettings( data class ConnectionSettings(
val host: String, val host: String,
val port: Int, val port: Int,
/** Server password, or null when the server needs none. */
val password: String? = null,
) { ) {
companion object { companion object {
/** First-run defaults, used until the user connects at least once. */ /** First-run defaults, used until the user connects at least once. */
@@ -24,6 +24,48 @@ class SettingsRepository(private val context: Context) {
private object Keys { private object Keys {
val HOST = stringPreferencesKey("host") val HOST = stringPreferencesKey("host")
val PORT = intPreferencesKey("port") 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. */
val albumViewMode: Flow<AlbumViewMode> =
context.dataStore.data.map { prefs -> AlbumViewMode.fromName(prefs[Keys.ALBUM_VIEW_MODE]) }
suspend fun setAlbumViewMode(mode: AlbumViewMode) {
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)
}
} }
/** /**
@@ -36,6 +78,7 @@ class SettingsRepository(private val context: Context) {
ConnectionSettings( ConnectionSettings(
host = host, host = host,
port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port, port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port,
password = prefs[Keys.PASSWORD],
) )
} }
@@ -48,6 +91,10 @@ class SettingsRepository(private val context: Context) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[Keys.HOST] = settings.host prefs[Keys.HOST] = settings.host
prefs[Keys.PORT] = settings.port prefs[Keys.PORT] = settings.port
// Drop the key entirely for a password-less server so we never persist a
// stray empty string (and a later read comes back as null, not "").
val pw = settings.password
if (pw.isNullOrBlank()) prefs.remove(Keys.PASSWORD) else prefs[Keys.PASSWORD] = pw
} }
} }
@@ -55,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
@@ -231,6 +234,27 @@ class MpdClient(
MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums())) MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
} }
/** Trigger a database scan for new/changed/removed files (whole library). */
suspend fun updateDatabase() = run(MpdCommands.update())
/** 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. */ /** Replace the queue with an album and start playing it. */
suspend fun playAlbum( suspend fun playAlbum(
album: String, album: String,
@@ -358,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()
@@ -367,7 +396,29 @@ 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
// 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 { try {
val result = withContext(ioDispatcher) { block(conn) } val result = withContext(ioDispatcher) { block(conn) }
returnArtConnection(conn) returnArtConnection(conn)
@@ -379,21 +430,20 @@ class MpdClient(
runCatching { conn.close() } // discard a broken connection runCatching { conn.close() } // discard a broken connection
throw e 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() } 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 =
@@ -477,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) {
@@ -533,8 +586,13 @@ class MpdClient(
private const val RECONNECT_BACKOFF_MS = 1_000L private const val RECONNECT_BACKOFF_MS = 1_000L
private const val MAX_RECONNECT_ATTEMPTS = 5 private const val MAX_RECONNECT_ATTEMPTS = 5
/** Idle subsystems that change something [status]/[currentSong] reflects. */ /**
private val REFRESHING_SUBSYSTEMS = setOf("player", "mixer", "options", "playlist") * Idle subsystems that change something [status]/[currentSong] reflects. `update` fires when
* a database scan starts and finishes (toggling `status.updating`); `database` fires when the
* scan actually changed the library.
*/
private val REFRESHING_SUBSYSTEMS =
setOf("player", "mixer", "options", "playlist", "update", "database")
} }
} }
@@ -94,6 +94,21 @@ object MpdCommands {
// --- Database / library ------------------------------------------------- // --- Database / library -------------------------------------------------
/**
* Scan the music database for new, modified, and removed files (whole library when [path] is
* null). Respects file modification times, so unchanged files are skipped. Returns immediately
* with `updating_db: <jobid>`; the scan runs in the background.
*/
fun update(path: String? = null) =
if (path == null) MpdProtocol.command("update") else MpdProtocol.command("update", path)
/**
* Like [update], but also re-reads files whose modification time is unchanged — the "hard"
* refresh, for when tags changed without touching mtime. Heavier on the server.
*/
fun rescan(path: String? = null) =
if (path == null) MpdProtocol.command("rescan") else MpdProtocol.command("rescan", path)
/** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */ /** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */
fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist") fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist")
@@ -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(),
@@ -43,6 +43,7 @@ data class MpdStatus(
val bitrate: Int?, // instantaneous kbps val bitrate: Int?, // instantaneous kbps
val audio: String?, // e.g. "44100:16:2" val audio: String?, // e.g. "44100:16:2"
val error: String?, // last player error, if any val error: String?, // last player error, if any
val updating: Boolean = false, // true while the server is scanning its database (`updating_db`)
) { ) {
companion object { companion object {
fun from(values: Map<String, String>): MpdStatus = fun from(values: Map<String, String>): MpdStatus =
@@ -64,6 +65,8 @@ data class MpdStatus(
bitrate = values["bitrate"]?.toIntOrNull(), bitrate = values["bitrate"]?.toIntOrNull(),
audio = values["audio"], audio = values["audio"],
error = values["error"], error = values["error"],
// Present (a job id) only while a database update/rescan is running.
updating = values["updating_db"] != null,
) )
} }
} }
@@ -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?,
@@ -1,6 +1,7 @@
package ca.ksamad.encore.playback package ca.ksamad.encore.playback
import android.content.Context import android.content.Context
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.data.ConnectionSettings import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.data.SettingsRepository import ca.ksamad.encore.data.SettingsRepository
import ca.ksamad.encore.mpd.MpdClient import ca.ksamad.encore.mpd.MpdClient
@@ -8,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
@@ -47,6 +49,38 @@ class MpdConnectionManager(context: Context) {
val settings: StateFlow<ConnectionSettings> = val settings: StateFlow<ConnectionSettings> =
settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT) settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)
/** Persisted albums-library layout preference. */
val albumViewMode: StateFlow<AlbumViewMode> =
settingsRepo.albumViewMode.stateIn(scope, SharingStarted.Eagerly, AlbumViewMode.DEFAULT)
fun setAlbumViewMode(mode: AlbumViewMode) {
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
@@ -72,7 +106,7 @@ class MpdConnectionManager(context: Context) {
scope.launch { scope.launch {
val saved = settingsRepo.settingsOrNull.first() val saved = settingsRepo.settingsOrNull.first()
if (saved != null) { if (saved != null) {
connect(saved.host, saved.port) connect(saved.host, saved.port, saved.password)
connectionState.first { it !is MpdConnectionState.Disconnected } connectionState.first { it !is MpdConnectionState.Disconnected }
} }
_bootstrapping.value = false _bootstrapping.value = false
@@ -100,12 +134,15 @@ class MpdConnectionManager(context: Context) {
fun connect( fun connect(
host: String, host: String,
port: Int, port: Int,
password: String? = null,
) { ) {
val trimmed = host.trim() val trimmed = host.trim()
// Treat a blank password as "no password" everywhere downstream.
val pw = password?.trim()?.ifBlank { null }
_serverHost.value = trimmed _serverHost.value = trimmed
scope.launch { scope.launch {
settingsRepo.save(ConnectionSettings(trimmed, port)) settingsRepo.save(ConnectionSettings(trimmed, port, pw))
runCatching { client.connect(trimmed, port) } runCatching { client.connect(trimmed, port, pw) }
} }
} }
@@ -164,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) }
@@ -196,10 +236,29 @@ class MpdConnectionManager(context: Context) {
suspend fun loadAlbums(): List<MpdAlbum> = suspend fun loadAlbums(): List<MpdAlbum> =
runCatching { client.albums() }.getOrDefault(emptyList()) runCatching { client.albums() }.getOrDefault(emptyList())
/** Ask the server to scan for new/changed/removed files. */
fun updateDatabase() = fire { updateDatabase() }
/** Ask the server to fully re-read the database, including unchanged files. */
fun rescanDatabase() = fire { rescanDatabase() }
/** One-shot play-queue fetch; returns empty on any failure. */ /** One-shot play-queue fetch; returns empty on any failure. */
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 {
@@ -210,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)
} }
@@ -232,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
@@ -38,6 +40,7 @@ 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.draw.clip import androidx.compose.ui.draw.clip
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 ca.ksamad.encore.mpd.model.MpdAlbum import ca.ksamad.encore.mpd.model.MpdAlbum
@@ -47,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
@@ -65,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()
@@ -90,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()
@@ -101,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")
@@ -162,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,
) { ) {
@@ -176,12 +195,13 @@ 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(
album.name, album.name,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -190,10 +210,12 @@ private fun AlbumDetailHeader(
it, it,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
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),
@@ -212,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(
@@ -263,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
@@ -1,12 +1,21 @@
package ca.ksamad.encore.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
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.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.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
@@ -44,9 +53,12 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
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 ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.mpd.model.MpdAlbum import ca.ksamad.encore.mpd.model.MpdAlbum
import ca.ksamad.encore.playback.AlbumArt import ca.ksamad.encore.playback.AlbumArt
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -59,9 +71,16 @@ 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 gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
// null = still loading. // null = still loading.
val albums by val albums by
produceState<List<MpdAlbum>?>(initialValue = null) { produceState<List<MpdAlbum>?>(initialValue = null) {
@@ -72,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() }
@@ -116,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")
} }
@@ -126,7 +146,7 @@ fun AlbumsScreen(
onClick = { onClick = {
if (searching) { if (searching) {
searching = false searching = false
query = "" onQueryChange("")
} else { } else {
onBack() onBack()
} }
@@ -137,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) {
@@ -173,8 +193,23 @@ fun AlbumsScreen(
} }
} }
viewMode == AlbumViewMode.Grid -> {
LazyVerticalGrid(
columns = GridCells.Fixed(gridColumns),
state = gridState,
modifier = Modifier.padding(innerPadding),
contentPadding = PaddingValues(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
AlbumGridCell(album = album, onTap = { onOpenAlbum(album) })
}
}
}
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,
@@ -229,8 +264,46 @@ private fun SwipeableAlbumRow(
} }
/** /**
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard; * One album tile for the grid layout: square cover art with the album name in bold underneath and
* the surrounding [TopAppBar] handles clearing/closing. * the artist in a smaller, muted font below that. Tapping opens the album ([onTap]).
*/
@Composable
private fun AlbumGridCell(
album: MpdAlbum,
onTap: () -> Unit,
) {
Column(modifier = Modifier.clickable(onClick = onTap)) {
ArtImage(
model = AlbumArt(album.name, album.albumArtist),
iconSize = 48.dp,
modifier =
Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)),
)
Text(
album.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
album.albumArtist?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -259,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"
}
@@ -1,5 +1,6 @@
package ca.ksamad.encore.ui package ca.ksamad.encore.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -7,8 +8,15 @@ 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.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -22,6 +30,8 @@ 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.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -55,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.
@@ -65,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()
@@ -75,6 +100,18 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
} }
is AppScreen.Player -> { is AppScreen.Player -> {
// The overlays form a back stack the OS knows nothing about (we don't use a
// nav library). Intercept the system/gesture back button and pop one level so
// it mirrors the on-screen back arrow, instead of falling through and closing
// the app. Only enabled when there's somewhere to pop back to.
BackHandler(enabled = overlay != PlayerOverlay.None) {
if (detailAlbum != null) {
detailAlbum = null
} else {
overlay = PlayerOverlay.None
}
}
when (overlay) { when (overlay) {
PlayerOverlay.Settings -> { PlayerOverlay.Settings -> {
SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None }) SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
@@ -95,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 },
) )
@@ -145,6 +186,8 @@ private fun ConnectScreen(
// that stick (rememberSaveable also survives rotation/process death). // that stick (rememberSaveable also survives rotation/process death).
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) } var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) } var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) }
var password by rememberSaveable(saved.password) { mutableStateOf(saved.password ?: "") }
var showPassword by rememberSaveable { mutableStateOf(false) }
Column( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp),
@@ -177,10 +220,32 @@ private fun ConnectScreen(
), ),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password (optional)") },
singleLine = true,
visualTransformation =
if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions =
androidx.compose.foundation.text.KeyboardOptions(
keyboardType = KeyboardType.Password
),
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (showPassword) "Hide password" else "Show password",
)
}
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Button( Button(
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600) }, onClick = { vm.connect(host, port.toIntOrNull() ?: 6600, password) },
enabled = host.isNotBlank(), enabled = host.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -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,11 +13,14 @@ 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.filled.Cast import androidx.compose.material.icons.filled.Cast
import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
@@ -26,9 +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.material.icons.filled.VolumeUp 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,14 +109,70 @@ 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
// 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) { IconButton(onClick = onOpenQueue) {
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue") 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( ArtImage(
model = song?.uri?.let { SongArt(it) }, model = artModel,
iconSize = 96.dp, iconSize = 96.dp,
modifier = 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 -------------------------------------------------- // --- Track metadata --------------------------------------------------
Text( Text(
text = song?.title ?: song?.uri ?: "Nothing playing", text = song?.title ?: song?.uri ?: "Nothing playing",
@@ -138,18 +295,33 @@ fun NowPlayingScreen(
AudioPropertyPills(status = status) AudioPropertyPills(status = status)
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(24.dp))
SeekBar(status = status, onSeek = { vm.seekTo(it) }) SeekBar(status = status, onSeek = { vm.seekTo(it) })
Spacer(Modifier.height(16.dp)) 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 playing = status?.state == PlayerState.PLAY
val repeatOn = status?.repeat == true
val shuffleOn = status?.random == true
Row( Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, 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)) { IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
Icon( Icon(
Icons.Filled.SkipPrevious, Icons.Filled.SkipPrevious,
@@ -171,49 +343,21 @@ fun NowPlayingScreen(
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) )
} }
} IconButton(onClick = { vm.setRandom(!shuffleOn) }, modifier = Modifier.size(48.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 = {
Icon( Icon(
Icons.Filled.Shuffle, Icons.Filled.Shuffle,
contentDescription = null, contentDescription = if (shuffleOn) "Shuffle on" else "Shuffle off",
modifier = Modifier.size(18.dp), 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,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?) {
@@ -372,7 +460,7 @@ private fun VolumeControl(
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
Icons.Filled.VolumeUp, Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Volume", contentDescription = "Volume",
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import ca.ksamad.encore.EncoreApplication import ca.ksamad.encore.EncoreApplication
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.data.ConnectionSettings import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.mpd.MpdConnectionState import ca.ksamad.encore.mpd.MpdConnectionState
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -39,6 +40,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
val currentSong = manager.currentSong val currentSong = manager.currentSong
val serverHost = manager.serverHost val serverHost = manager.serverHost
val settings = manager.settings val settings = manager.settings
val albumViewMode = manager.albumViewMode
val volumeStep = manager.volumeStep
val gridColumns = manager.gridColumns
val screen: StateFlow<AppScreen> = val screen: StateFlow<AppScreen> =
combine( combine(
@@ -64,7 +68,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun connect( fun connect(
host: String, host: String,
port: Int, port: Int,
) = manager.connect(host, port) password: String? = null,
) = manager.connect(host, port, password)
fun disconnect() = manager.disconnect() fun disconnect() = manager.disconnect()
@@ -86,6 +91,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun setConsume(on: Boolean) = manager.setConsume(on) fun setConsume(on: Boolean) = manager.setConsume(on)
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?,
@@ -101,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)
@@ -112,6 +125,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
suspend fun loadAlbums() = manager.loadAlbums() suspend fun loadAlbums() = manager.loadAlbums()
fun updateDatabase() = manager.updateDatabase()
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)
@@ -12,9 +12,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
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.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -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)),
) )
@@ -256,7 +269,7 @@ private fun QueueRow(
}, },
trailingContent = trailingContent =
if (isCurrent) { if (isCurrent) {
{ Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") } { Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Now playing") }
} else { } else {
song.duration?.let { { Text(formatDuration(it)) } } song.duration?.let { { Text(formatDuration(it)) } }
}, },
@@ -1,21 +1,30 @@
package ca.ksamad.encore.ui 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.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.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
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -23,6 +32,11 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -32,14 +46,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.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
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.launch
/** /**
* Settings: shows the current server and offers to switch servers or reset all saved settings. * Settings: shows the current server and offers to switch servers or reset all saved settings.
@@ -54,6 +74,15 @@ fun SettingsScreen(
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle() val status by vm.status.collectAsStateWithLifecycle()
var confirmReset by remember { mutableStateOf(false) } var confirmReset by remember { mutableStateOf(false) }
var refreshMenuOpen by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun flash(message: String) {
snackbarHostState.currentSnackbarData?.dismiss()
scope.launch { snackbarHostState.showSnackbar(message) }
}
// Server statistics (`stats`), fetched once when the screen opens. `null` // Server statistics (`stats`), fetched once when the screen opens. `null`
// means either still loading or unavailable — [statsLoaded] disambiguates. // means either still loading or unavailable — [statsLoaded] disambiguates.
@@ -74,7 +103,8 @@ fun SettingsScreen(
} }
}, },
) )
} },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column( Column(
modifier = modifier =
@@ -119,6 +149,159 @@ 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,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 8.dp, top = 20.dp, bottom = 4.dp),
)
val albumViewMode by vm.albumViewMode.collectAsStateWithLifecycle()
ListItem(
headlineContent = { Text("Album view") },
supportingContent = { Text("How the albums library is laid out") },
trailingContent = {
SingleChoiceSegmentedButtonRow {
AlbumViewMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = albumViewMode == mode,
onClick = { vm.setAlbumViewMode(mode) },
shape =
SegmentedButtonDefaults.itemShape(
index = index,
count = AlbumViewMode.entries.size,
),
icon = {},
) {
Text(
when (mode) {
AlbumViewMode.List -> "List"
AlbumViewMode.Grid -> "Grid"
}
)
}
}
}
},
)
// 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.
val refreshing = status?.updating == true
ListItem(
modifier =
Modifier.clickable(enabled = !refreshing) {
vm.updateDatabase()
flash("Library refresh started")
},
headlineContent = { Text("Refresh library") },
supportingContent = {
Text(if (refreshing) "Refreshing…" else "Scan for new and changed files")
},
trailingContent = {
if (refreshing) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
} else {
Box {
IconButton(onClick = { refreshMenuOpen = true }) {
Icon(
Icons.Filled.MoreVert,
contentDescription = "More refresh options",
)
}
DropdownMenu(
expanded = refreshMenuOpen,
onDismissRequest = { refreshMenuOpen = false },
) {
DropdownMenuItem(
text = { Text("Full rescan") },
onClick = {
refreshMenuOpen = false
vm.rescanDatabase()
flash("Full rescan started")
},
)
}
}
}
},
)
// 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,82 +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
import kotlin.math.roundToInt
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) }
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
@@ -93,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) {
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 11 and below (fullBackupContent). Exclude the connection-settings
DataStore so the saved server + password never leave the device in a backup.
-->
<full-backup-content>
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
</full-backup-content>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android 12+ (dataExtractionRules). Keep the connection-settings DataStore
(saved server + password) out of both cloud backups and device-to-device
transfers.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
</device-transfer>
</data-extraction-rules>
@@ -37,6 +37,7 @@ class MpdModelTest {
assertEquals(3, s.song) assertEquals(3, s.song)
assertEquals(42.5, s.elapsed!!, 0.0001) assertEquals(42.5, s.elapsed!!, 0.0001)
assertEquals(215.3, s.duration!!, 0.0001) assertEquals(215.3, s.duration!!, 0.0001)
assertFalse(s.updating) // no updating_db field → not scanning
} }
@Test @Test
@@ -47,6 +48,12 @@ class MpdModelTest {
assertEquals(0, s.playlistLength) assertEquals(0, s.playlistLength)
} }
@Test
fun status_updatingDbFlagsScanInProgress() {
val s = MpdStatus.from(mapOf("state" to "stop", "updating_db" to "3"))
assertTrue(s.updating)
}
@Test @Test
fun song_parsesTagsAndDuration() { fun song_parsesTagsAndDuration() {
val song = val song =
@@ -0,0 +1,6 @@
First release.
* Connect to an MPD server and control playback
* Browse albums as a list or grid, with cover art and search
* Manage the play queue with swipe actions
* Media notification and hardware volume-key support
@@ -0,0 +1,16 @@
Encore is a remote control for a Music Player Daemon (MPD) server on your local network. It focuses on everyday playback: see what's playing, control the queue, and browse your library.
Features:
* Browse your library by album, as a list or a two-column grid
* Album and track cover art
* Search albums and artists
* View the play queue; add whole albums or single tracks
* Swipe an album or track to queue it or play it next
* Playback controls with a seek bar
* Adjust server volume, including with the hardware volume keys
* Media notification and lock-screen controls
* Multi-disc album support and server statistics
* Consume mode toggle
Encore talks directly to the MPD server you configure. It uses no cloud service, no accounts, and no tracking or analytics.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1 @@
A modern Android remote for your Music Player Daemon (MPD) server.
@@ -0,0 +1 @@
Encore
+25
View File
@@ -0,0 +1,25 @@
Categories:
- Multimedia
License: Apache-2.0
AuthorName: Karim Abdul-Samad
SourceCode: https://git.ksamad.ca/kas2020/encore
IssueTracker: https://git.ksamad.ca/kas2020/encore/issues
RepoType: git
Repo: https://git.ksamad.ca/kas2020/encore.git
Builds:
- versionName: 0.1.0
versionCode: 1
commit: v0.1.0
subdir: app
gradle:
- yes
Binaries: https://git.ksamad.ca/kas2020/encore/releases/download/v%v/encore-%v.apk
AllowedAPKSigningKeys: 1c67813ac742c2421ea47220de072ffa6771414d0b32be21daabf622fe9eccb3
AutoUpdateMode: Version v%v
UpdateCheckMode: Tags
CurrentVersion: 0.1.0
CurrentVersionCode: 1
+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]
Vendored
-93
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
# Copy this file to `keystore.properties` (which is gitignored) and fill in your
# real values. This file — the .example — carries NO secrets and is safe to commit.
#
# `storeFile` is the path to your release keystore. Keep the keystore OUTSIDE the
# repo (e.g. ~/.keys/encore-release.jks) and back it up offline — losing it means
# you can never ship another signed update on your own channels.
storeFile=/absolute/path/to/encore-release.jks
storePassword=CHANGE_ME
keyAlias=encore
keyPassword=CHANGE_ME