Compare commits

...
8 Commits
Author SHA1 Message Date
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
57 changed files with 659 additions and 12 deletions
+5
View File
@@ -7,6 +7,11 @@ build/
*.aab
local.properties
# Release signing — NEVER commit the key or its passwords
keystore.properties
*.jks
*.keystore
# Nix
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.
+4
View File
@@ -101,6 +101,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).
- [`docs/TODO.md`](docs/TODO.md) — near-term roadmap / known bugs.
## License
Encore is licensed under the [Apache License 2.0](LICENSE).
## Version notes
Pinned in `gradle/libs.versions.toml`: AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01,
+28
View File
@@ -1,9 +1,21 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
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 {
namespace = "ca.ksamad.encore"
compileSdk = 35
@@ -21,8 +33,24 @@ android {
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 {
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
// material-icons-extended: it bundles thousands of vector icons, and
// R8 strips everything we don't reference (debug builds can't shrink,
+4
View File
@@ -12,7 +12,11 @@
<application
android:name=".EncoreApplication"
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:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Encore">
<activity
@@ -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(
val host: String,
val port: Int,
/** Server password, or null when the server needs none. */
val password: String? = null,
) {
companion object {
/** First-run defaults, used until the user connects at least once. */
@@ -24,6 +24,16 @@ class SettingsRepository(private val context: Context) {
private object Keys {
val HOST = stringPreferencesKey("host")
val PORT = intPreferencesKey("port")
val PASSWORD = stringPreferencesKey("password")
val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode")
}
/** 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 }
}
/**
@@ -36,6 +46,7 @@ class SettingsRepository(private val context: Context) {
ConnectionSettings(
host = host,
port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port,
password = prefs[Keys.PASSWORD],
)
}
@@ -48,6 +59,10 @@ class SettingsRepository(private val context: Context) {
context.dataStore.edit { prefs ->
prefs[Keys.HOST] = settings.host
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
}
}
@@ -231,6 +231,12 @@ class MpdClient(
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 an album and start playing it. */
suspend fun playAlbum(
album: String,
@@ -533,8 +539,13 @@ class MpdClient(
private const val RECONNECT_BACKOFF_MS = 1_000L
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 -------------------------------------------------
/**
* 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). */
fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist")
@@ -43,6 +43,7 @@ data class MpdStatus(
val bitrate: Int?, // instantaneous kbps
val audio: String?, // e.g. "44100:16:2"
val error: String?, // last player error, if any
val updating: Boolean = false, // true while the server is scanning its database (`updating_db`)
) {
companion object {
fun from(values: Map<String, String>): MpdStatus =
@@ -64,6 +65,8 @@ data class MpdStatus(
bitrate = values["bitrate"]?.toIntOrNull(),
audio = values["audio"],
error = values["error"],
// Present (a job id) only while a database update/rescan is running.
updating = values["updating_db"] != null,
)
}
}
@@ -1,6 +1,7 @@
package ca.ksamad.encore.playback
import android.content.Context
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.data.SettingsRepository
import ca.ksamad.encore.mpd.MpdClient
@@ -47,6 +48,14 @@ class MpdConnectionManager(context: Context) {
val settings: StateFlow<ConnectionSettings> =
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) }
}
// True until the startup auto-connect decision has been made.
private val _bootstrapping = MutableStateFlow(true)
val bootstrapping: StateFlow<Boolean> = _bootstrapping
@@ -72,7 +81,7 @@ class MpdConnectionManager(context: Context) {
scope.launch {
val saved = settingsRepo.settingsOrNull.first()
if (saved != null) {
connect(saved.host, saved.port)
connect(saved.host, saved.port, saved.password)
connectionState.first { it !is MpdConnectionState.Disconnected }
}
_bootstrapping.value = false
@@ -100,12 +109,15 @@ class MpdConnectionManager(context: Context) {
fun connect(
host: String,
port: Int,
password: String? = null,
) {
val trimmed = host.trim()
// Treat a blank password as "no password" everywhere downstream.
val pw = password?.trim()?.ifBlank { null }
_serverHost.value = trimmed
scope.launch {
settingsRepo.save(ConnectionSettings(trimmed, port))
runCatching { client.connect(trimmed, port) }
settingsRepo.save(ConnectionSettings(trimmed, port, pw))
runCatching { client.connect(trimmed, port, pw) }
}
}
@@ -196,6 +208,12 @@ class MpdConnectionManager(context: Context) {
suspend fun loadAlbums(): List<MpdAlbum> =
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. */
suspend fun loadQueue(): List<MpdSong> =
runCatching { client.queue() }.getOrDefault(emptyList())
@@ -38,6 +38,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ca.ksamad.encore.mpd.model.MpdAlbum
@@ -182,6 +183,7 @@ private fun AlbumDetailHeader(
Text(
album.name,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
@@ -190,6 +192,7 @@ private fun AlbumDetailHeader(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -1,12 +1,19 @@
package ca.ksamad.encore.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
@@ -44,9 +51,12 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.style.TextOverflow
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.playback.AlbumArt
import kotlinx.coroutines.launch
@@ -62,6 +72,8 @@ fun AlbumsScreen(
onBack: () -> Unit,
onOpenAlbum: (MpdAlbum) -> Unit,
) {
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
// null = still loading.
val albums by
produceState<List<MpdAlbum>?>(initialValue = null) {
@@ -173,6 +185,20 @@ fun AlbumsScreen(
}
}
viewMode == AlbumViewMode.Grid -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
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 -> {
LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
@@ -228,6 +254,42 @@ private fun SwipeableAlbumRow(
}
}
/**
* One album tile for the grid layout: square cover art with the album name in bold underneath and
* 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;
* the surrounding [TopAppBar] handles clearing/closing.
@@ -1,5 +1,6 @@
package ca.ksamad.encore.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -7,8 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -22,6 +28,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -75,6 +83,18 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
}
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) {
PlayerOverlay.Settings -> {
SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
@@ -145,6 +165,8 @@ private fun ConnectScreen(
// that stick (rememberSaveable also survives rotation/process death).
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
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(
modifier = Modifier.fillMaxSize().padding(24.dp),
@@ -177,10 +199,32 @@ private fun ConnectScreen(
),
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))
Button(
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600) },
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600, password) },
enabled = host.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.LibraryMusic
import androidx.compose.material.icons.filled.Pause
@@ -26,7 +27,6 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
@@ -372,7 +372,7 @@ private fun VolumeControl(
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.VolumeUp,
Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Volume",
modifier = Modifier.size(24.dp),
)
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import ca.ksamad.encore.EncoreApplication
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.mpd.MpdConnectionState
import kotlinx.coroutines.flow.SharingStarted
@@ -39,6 +40,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
val currentSong = manager.currentSong
val serverHost = manager.serverHost
val settings = manager.settings
val albumViewMode = manager.albumViewMode
val screen: StateFlow<AppScreen> =
combine(
@@ -64,7 +66,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun connect(
host: String,
port: Int,
) = manager.connect(host, port)
password: String? = null,
) = manager.connect(host, port, password)
fun disconnect() = manager.disconnect()
@@ -86,6 +89,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun setConsume(on: Boolean) = manager.setConsume(on)
fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode)
fun playAlbum(
album: String,
albumArtist: String?,
@@ -112,6 +117,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
suspend fun loadAlbums() = manager.loadAlbums()
fun updateDatabase() = manager.updateDatabase()
fun rescanDatabase() = manager.rescanDatabase()
fun playQueueItem(songId: Int) = manager.playQueueItem(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.material.icons.Icons
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.DeleteSweep
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -256,7 +256,7 @@ private fun QueueRow(
},
trailingContent =
if (isCurrent) {
{ Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") }
{ Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Now playing") }
} else {
song.duration?.let { { Text(formatDuration(it)) } }
},
@@ -1,6 +1,7 @@
package ca.ksamad.encore.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,10 +13,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -23,6 +27,11 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.Text
import androidx.compose.material3.TextButton
@@ -32,14 +41,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.mpd.model.MpdStatistics
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.launch
/**
* Settings: shows the current server and offers to switch servers or reset all saved settings.
@@ -54,6 +66,15 @@ fun SettingsScreen(
val settings by vm.settings.collectAsStateWithLifecycle()
val status by vm.status.collectAsStateWithLifecycle()
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`
// means either still loading or unavailable — [statsLoaded] disambiguates.
@@ -74,7 +95,8 @@ fun SettingsScreen(
}
},
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(
modifier =
@@ -119,6 +141,84 @@ fun SettingsScreen(
},
)
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"
}
)
}
}
}
},
)
// 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")
},
)
}
}
}
},
)
Text(
"Server statistics",
style = MaterialTheme.typography.titleSmall,
@@ -22,6 +22,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Fraction of a row's width a swipe must cross before the action fires (vs. the 56.dp default). */
private const val SWIPE_TRIGGER_FRACTION = 0.5f
/**
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue]
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
@@ -39,6 +42,10 @@ fun QueueSwipeRow(
) {
val state =
rememberSwipeToDismissBoxState(
// Require the swipe to cross half the row's width before it counts, rather
// than the default fixed 56.dp — a small drag or flick was triggering the
// queue/play-next action too easily.
positionalThreshold = { totalDistance -> totalDistance * SWIPE_TRIGGER_FRACTION },
confirmValueChange = { target ->
when (target) {
SwipeToDismissBoxValue.StartToEnd -> {
@@ -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(42.5, s.elapsed!!, 0.0001)
assertEquals(215.3, s.duration!!, 0.0001)
assertFalse(s.updating) // no updating_db field → not scanning
}
@Test
@@ -47,6 +48,12 @@ class MpdModelTest {
assertEquals(0, s.playlistLength)
}
@Test
fun status_updatingDbFlagsScanInProgress() {
val s = MpdStatus.from(mapOf("state" to "stop", "updating_db" to "3"))
assertTrue(s.updating)
}
@Test
fun song_parsesTagsAndDuration() {
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: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 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
+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