Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22d570e191
|
||
|
|
330de1eda9
|
||
|
|
bbc92278f4
|
||
|
|
d9b665cdca
|
||
|
|
3d57fca003
|
||
|
|
de13d3b36b
|
||
|
|
665df77fc4
|
||
|
|
3abd2c4c96
|
@@ -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-*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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).
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,16 @@ 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(
|
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 +59,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,12 @@ 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 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,
|
||||||
@@ -533,8 +539,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")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -47,6 +48,14 @@ 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) }
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +81,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 +109,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +208,12 @@ 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())
|
||||||
|
|||||||
@@ -38,6 +38,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
|
||||||
@@ -182,6 +183,7 @@ private fun AlbumDetailHeader(
|
|||||||
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,6 +192,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
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.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.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 +51,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
|
||||||
@@ -62,6 +72,8 @@ fun AlbumsScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenAlbum: (MpdAlbum) -> Unit,
|
onOpenAlbum: (MpdAlbum) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// null = still loading.
|
// null = still loading.
|
||||||
val albums by
|
val albums by
|
||||||
produceState<List<MpdAlbum>?>(initialValue = null) {
|
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 -> {
|
else -> {
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
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;
|
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard;
|
||||||
* the surrounding [TopAppBar] handles clearing/closing.
|
* the surrounding [TopAppBar] handles clearing/closing.
|
||||||
|
|||||||
@@ -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,13 @@ 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.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 +28,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
|
||||||
@@ -75,6 +83,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 })
|
||||||
@@ -145,6 +165,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 +199,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(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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.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,7 +27,6 @@ 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.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -372,7 +372,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,7 @@ 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 screen: StateFlow<AppScreen> =
|
val screen: StateFlow<AppScreen> =
|
||||||
combine(
|
combine(
|
||||||
@@ -64,7 +66,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 +89,8 @@ 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 playAlbum(
|
fun playAlbum(
|
||||||
album: String,
|
album: String,
|
||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
@@ -112,6 +117,10 @@ 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 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
|
||||||
@@ -256,7 +256,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,6 +1,7 @@
|
|||||||
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.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -12,10 +13,13 @@ 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.MoreVert
|
||||||
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 +27,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 +41,17 @@ 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.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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.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 +66,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 +95,8 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
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(
|
Text(
|
||||||
"Server statistics",
|
"Server statistics",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
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]
|
* 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
|
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
|
||||||
@@ -39,6 +42,10 @@ fun QueueSwipeRow(
|
|||||||
) {
|
) {
|
||||||
val state =
|
val state =
|
||||||
rememberSwipeToDismissBoxState(
|
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 ->
|
confirmValueChange = { target ->
|
||||||
when (target) {
|
when (target) {
|
||||||
SwipeToDismissBoxValue.StartToEnd -> {
|
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>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 521 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 683 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1007 B |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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.
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 629 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 166 KiB |
@@ -0,0 +1 @@
|
|||||||
|
A modern Android remote for your Music Player Daemon (MPD) server.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Encore
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||