Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2ae6adf8
|
||
|
|
163fa6c87e
|
||
|
|
62383accf7
|
||
|
|
22d570e191
|
||
|
|
330de1eda9
|
||
|
|
bbc92278f4
|
||
|
|
d9b665cdca
|
||
|
|
3d57fca003
|
||
|
|
de13d3b36b
|
||
|
|
665df77fc4
|
||
|
|
3abd2c4c96
|
@@ -7,6 +7,11 @@ build/
|
||||
*.aab
|
||||
local.properties
|
||||
|
||||
# Release signing — NEVER commit the key or its passwords
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Nix
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Encore
|
||||
|
||||
<img src="fastlane/metadata/android/en-US/images/icon.png" width="120" align="right" alt="Encore app icon">
|
||||
|
||||
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
||||
tested with **Nix**.
|
||||
|
||||
@@ -7,6 +9,12 @@ Jetpack Compose *is* the modern Android UI framework — declarative Kotlin UI t
|
||||
replaced the old XML layouts. Material 3 is Google's current design system, wired up here
|
||||
with dynamic (wallpaper-based) color on Android 12+.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Now Playing | Library | Queue | Settings |
|
||||
|:-----------:|:-------:|:-----:|:--------:|
|
||||
| <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="180" alt="Now Playing"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="180" alt="Album grid"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="180" alt="Queue"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="180" alt="Settings"> |
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -101,6 +109,10 @@ downloads through a man-in-the-middle proxy and storing their hashes — see
|
||||
real Android device/DAP over USB (and the NixOS `adb` notes).
|
||||
- [`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,
|
||||
|
||||
@@ -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,
|
||||
@@ -109,6 +137,7 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.androidx.palette)
|
||||
|
||||
// The Compose BOM aligns every Compose artifact to one tested version set,
|
||||
// so the individual Compose deps below are declared without versions.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,52 +4,40 @@ import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import ca.ksamad.encore.playback.VolumeKeyDispatcher
|
||||
import ca.ksamad.encore.ui.EncoreApp
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val requestNotificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
|
||||
|
||||
// Intercept the hardware volume keys while we're focused so they drive the
|
||||
// server volume without the system slider appearing (see VolumeKeyDispatcher).
|
||||
private val volumeKeys by lazy {
|
||||
VolumeKeyDispatcher((application as EncoreApplication).manager)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
maybeRequestNotificationPermission()
|
||||
setContent {
|
||||
EncoreTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
EncoreApp()
|
||||
}
|
||||
// No inset padding here: each screen's own Scaffold/TopAppBar (or, for the
|
||||
// plain-Column screens, their own systemBarsPadding) applies the system-bar
|
||||
// insets. Doing it at the root too would double the status-bar gap.
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
EncoreApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
||||
volumeKeys.dispatch(event) || super.dispatchKeyEvent(event)
|
||||
|
||||
// The media notification needs POST_NOTIFICATIONS on Android 13+. Without it
|
||||
// the foreground service still runs and cast volume still works, but the
|
||||
// now-playing notification / QS controls won't show.
|
||||
|
||||
@@ -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,48 @@ 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")
|
||||
val VOLUME_STEP = intPreferencesKey("volume_step")
|
||||
val GRID_COLUMNS = intPreferencesKey("grid_columns")
|
||||
}
|
||||
|
||||
/** How the albums library is laid out; emits on every change, defaulted when unset. */
|
||||
val albumViewMode: Flow<AlbumViewMode> =
|
||||
context.dataStore.data.map { prefs -> AlbumViewMode.fromName(prefs[Keys.ALBUM_VIEW_MODE]) }
|
||||
|
||||
suspend fun setAlbumViewMode(mode: AlbumViewMode) {
|
||||
context.dataStore.edit { prefs -> prefs[Keys.ALBUM_VIEW_MODE] = mode.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* How many percent each hardware volume-key press moves the server volume; emits on every
|
||||
* change, clamped into range and defaulted when unset.
|
||||
*/
|
||||
val volumeStep: Flow<Int> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
(prefs[Keys.VOLUME_STEP] ?: DEFAULT_VOLUME_STEP).coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP)
|
||||
}
|
||||
|
||||
suspend fun setVolumeStep(step: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.VOLUME_STEP] = step.coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many columns the albums grid layout uses; emits on every change, clamped into range and
|
||||
* defaulted when unset.
|
||||
*/
|
||||
val gridColumns: Flow<Int> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
(prefs[Keys.GRID_COLUMNS] ?: DEFAULT_GRID_COLUMNS).coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS)
|
||||
}
|
||||
|
||||
suspend fun setGridColumns(columns: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.GRID_COLUMNS] = columns.coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,6 +78,7 @@ class SettingsRepository(private val context: Context) {
|
||||
ConnectionSettings(
|
||||
host = host,
|
||||
port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port,
|
||||
password = prefs[Keys.PASSWORD],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,6 +91,10 @@ class SettingsRepository(private val context: Context) {
|
||||
context.dataStore.edit { prefs ->
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +102,16 @@ class SettingsRepository(private val context: Context) {
|
||||
suspend fun clear() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Volume-step bounds and default (percent per hardware volume-key press). */
|
||||
const val DEFAULT_VOLUME_STEP = 1
|
||||
const val MIN_VOLUME_STEP = 1
|
||||
const val MAX_VOLUME_STEP = 10
|
||||
|
||||
/** Albums-grid column-count bounds and default. */
|
||||
const val DEFAULT_GRID_COLUMNS = 2
|
||||
const val MIN_GRID_COLUMNS = 1
|
||||
const val MAX_GRID_COLUMNS = 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +129,15 @@ class MpdClient(
|
||||
/** Tear everything down; returns to [MpdConnectionState.Disconnected]. */
|
||||
suspend fun disconnect() {
|
||||
shuttingDown = true
|
||||
stopBackgroundJobs()
|
||||
// Closing the idle socket unblocks the loop's parked `idle` read.
|
||||
// Close the sockets *before* joining the loops. The idle loop is parked in a blocking
|
||||
// socket read that coroutine cancellation cannot interrupt, so closing the socket is what
|
||||
// unblocks it. If we joined first (as this used to), disconnect() would hang until the
|
||||
// server happened to push an idle event — which on a quiet server can be a very long time.
|
||||
withContext(ioDispatcher) {
|
||||
idleConn?.close()
|
||||
commandConn?.close()
|
||||
}
|
||||
stopBackgroundJobs()
|
||||
closeArtConnections()
|
||||
idleConn = null
|
||||
commandConn = null
|
||||
@@ -231,6 +234,27 @@ 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 [uris] (in the given order) and start playing the first. Used when the
|
||||
* user taps a track to play an album from that track onward. No-op on an empty list.
|
||||
*/
|
||||
suspend fun playTracks(uris: List<String>) {
|
||||
if (uris.isEmpty()) return
|
||||
withCommand { conn ->
|
||||
conn.executeList(
|
||||
MpdCommands.clear(),
|
||||
*uris.map { MpdCommands.add(it) }.toTypedArray(),
|
||||
MpdCommands.play(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the queue with an album and start playing it. */
|
||||
suspend fun playAlbum(
|
||||
album: String,
|
||||
@@ -358,8 +382,13 @@ class MpdClient(
|
||||
// Cover fetches run on their own small pool of connections rather than the
|
||||
// command connection, so (a) many covers load in parallel while scrolling and
|
||||
// (b) art never blocks play/pause/volume. Connections are opened lazily, up
|
||||
// to ART_POOL_SIZE (bounded by the semaphore), reused when idle, and dropped
|
||||
// on any transport error (a stale/timed-out one just gets reopened).
|
||||
// to ART_POOL_SIZE (bounded by the semaphore), and reused when idle.
|
||||
//
|
||||
// Unlike the command connection, these carry no keepalive, so MPD reaps them
|
||||
// after its connection_timeout (idle art is fetched rarely). A borrowed pooled
|
||||
// connection may therefore be dead; reusing it throws a transport error. When
|
||||
// that happens we discard it and retry once on a freshly opened connection, so
|
||||
// a stale socket can't blank a cover until the app is restarted.
|
||||
|
||||
private val artSemaphore = Semaphore(ART_POOL_SIZE)
|
||||
private val artPoolLock = Any()
|
||||
@@ -367,33 +396,54 @@ class MpdClient(
|
||||
|
||||
private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
|
||||
artSemaphore.withPermit {
|
||||
val conn = borrowArtConnection()
|
||||
try {
|
||||
val result = withContext(ioDispatcher) { block(conn) }
|
||||
returnArtConnection(conn)
|
||||
result
|
||||
} catch (e: MpdAckException) {
|
||||
returnArtConnection(conn) // command refused, but connection is healthy
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
runCatching { conn.close() } // discard a broken connection
|
||||
throw e
|
||||
// First try a pooled connection, if any. A transport failure here means
|
||||
// it went stale — discard it (runArt already closed it) and fall through
|
||||
// to a fresh one. A server ACK is a real result and is not retried.
|
||||
borrowPooledArtConnection()?.let { pooled ->
|
||||
try {
|
||||
return@withPermit runArt(pooled, block)
|
||||
} catch (e: MpdAckException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
// stale pooled connection; retry on a fresh one below
|
||||
}
|
||||
}
|
||||
runArt(openArtConnection(), block)
|
||||
}
|
||||
|
||||
private suspend fun borrowArtConnection(): MpdConnection {
|
||||
/**
|
||||
* Run [block] on [conn], returning it to the pool on success (or a clean `ACK`, which leaves the
|
||||
* connection healthy) and closing it on any transport failure.
|
||||
*/
|
||||
private suspend fun <T> runArt(
|
||||
conn: MpdConnection,
|
||||
block: (MpdConnection) -> T,
|
||||
): T =
|
||||
try {
|
||||
val result = withContext(ioDispatcher) { block(conn) }
|
||||
returnArtConnection(conn)
|
||||
result
|
||||
} catch (e: MpdAckException) {
|
||||
returnArtConnection(conn) // command refused, but connection is healthy
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
runCatching { conn.close() } // discard a broken connection
|
||||
throw e
|
||||
}
|
||||
|
||||
/** Pop an idle pooled art connection, or null if the pool is empty. */
|
||||
private fun borrowPooledArtConnection(): MpdConnection? =
|
||||
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }
|
||||
?.let {
|
||||
return it
|
||||
}
|
||||
return withContext(ioDispatcher) {
|
||||
|
||||
/** Open, authenticate, and prime a brand-new art connection. */
|
||||
private suspend fun openArtConnection(): MpdConnection =
|
||||
withContext(ioDispatcher) {
|
||||
val h = host ?: throw MpdConnectionException("not connected")
|
||||
val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
|
||||
password?.let { conn.execute(MpdCommands.password(it)) }
|
||||
runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
|
||||
conn
|
||||
}
|
||||
}
|
||||
|
||||
private fun returnArtConnection(conn: MpdConnection) {
|
||||
val kept =
|
||||
@@ -477,8 +527,11 @@ class MpdClient(
|
||||
}
|
||||
|
||||
private suspend fun reconnectLoop() {
|
||||
stopBackgroundJobs()
|
||||
// Close first, then join — same reason as disconnect(): closing the sockets unblocks the
|
||||
// idle loop's parked blocking read so stopBackgroundJobs() doesn't wait on it. (The idle
|
||||
// loop's own error handler no-ops here because a reconnect is already in flight.)
|
||||
closeConnectionsQuietly()
|
||||
stopBackgroundJobs()
|
||||
_connectionState.value = MpdConnectionState.Connecting
|
||||
var attempt = 0
|
||||
while (scope.isActive && !shuttingDown) {
|
||||
@@ -533,8 +586,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")
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ data class MpdSong(
|
||||
val disc: String?,
|
||||
val date: String?,
|
||||
val genre: String?,
|
||||
val lastModified: String?, // "Last-Modified" ISO timestamp; used to cache-bust cover art
|
||||
val format: String?, // "Format" audio format "samplerate:bits:channels" (from the DB scan)
|
||||
val duration: Double?, // from "duration" (fractional) or legacy "Time"
|
||||
val pos: Int?, // queue position, present in queue listings
|
||||
val id: Int?, // stable queue id, present in queue listings
|
||||
@@ -39,6 +41,8 @@ data class MpdSong(
|
||||
disc = values["Disc"],
|
||||
date = values["Date"],
|
||||
genre = values["Genre"],
|
||||
lastModified = values["Last-Modified"],
|
||||
format = values["Format"],
|
||||
duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(),
|
||||
pos = values["Pos"]?.toIntOrNull(),
|
||||
id = values["Id"]?.toIntOrNull(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,16 @@ object ArtImageLoader {
|
||||
}
|
||||
.build()
|
||||
|
||||
/** Stable cache key per art request; shared by the keyer and the disk cache. */
|
||||
/**
|
||||
* Stable cache key per art request; shared by the keyer and the disk cache. For [SongArt] the
|
||||
* optional [SongArt.version] is appended so a re-tagged file (new mtime) lands on a fresh key
|
||||
* and refetches; album keys stay version-free so an album's tracks share one entry.
|
||||
*/
|
||||
private fun cacheKey(data: MpdArtData): String =
|
||||
when (data) {
|
||||
is SongArt -> "song:${data.uri}"
|
||||
is SongArt ->
|
||||
if (data.version.isNullOrEmpty()) "song:${data.uri}"
|
||||
else "song:${data.uri}@${data.version}"
|
||||
is AlbumArt -> "album:${data.albumArtist.orEmpty()}/${data.album}"
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,24 @@ package ca.ksamad.encore.playback
|
||||
*/
|
||||
sealed interface MpdArtData
|
||||
|
||||
/** Cover art for a specific song URI (used for now-playing and the notification). */
|
||||
data class SongArt(val uri: String) : MpdArtData
|
||||
/**
|
||||
* Cover art for a specific song URI (used for now-playing and the notification).
|
||||
*
|
||||
* [version] is an opaque cache-bust token — typically the file's `Last-Modified` timestamp. It has
|
||||
* no effect on how the art is fetched (only [uri] matters for that); it only participates in the
|
||||
* cache key, so when a track is re-tagged/re-imported (its mtime changes) the new art is fetched
|
||||
* automatically instead of serving a stale cached copy.
|
||||
*/
|
||||
data class SongArt(
|
||||
val uri: String,
|
||||
val version: String? = null,
|
||||
) : MpdArtData
|
||||
|
||||
/** Cover art for an album (a representative track is resolved server-side). */
|
||||
/**
|
||||
* Cover art for an album (a representative track is resolved server-side). Deliberately has no
|
||||
* per-track version: every track of an album must resolve to one cache entry (see the queue), so
|
||||
* album art is refreshed via the manual "Refresh artwork" action rather than mtime.
|
||||
*/
|
||||
data class AlbumArt(
|
||||
val album: String,
|
||||
val albumArtist: String?,
|
||||
|
||||
@@ -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
|
||||
@@ -8,6 +9,7 @@ import ca.ksamad.encore.mpd.MpdConnectionState
|
||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import coil3.SingletonImageLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -47,6 +49,38 @@ 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) }
|
||||
}
|
||||
|
||||
/** Persisted volume-key step (percent per press). */
|
||||
val volumeStep: StateFlow<Int> =
|
||||
settingsRepo.volumeStep.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
SettingsRepository.DEFAULT_VOLUME_STEP,
|
||||
)
|
||||
|
||||
fun setVolumeStep(step: Int) {
|
||||
scope.launch { settingsRepo.setVolumeStep(step) }
|
||||
}
|
||||
|
||||
/** Persisted albums-grid column count. */
|
||||
val gridColumns: StateFlow<Int> =
|
||||
settingsRepo.gridColumns.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
SettingsRepository.DEFAULT_GRID_COLUMNS,
|
||||
)
|
||||
|
||||
fun setGridColumns(columns: Int) {
|
||||
scope.launch { settingsRepo.setGridColumns(columns) }
|
||||
}
|
||||
|
||||
// True until the startup auto-connect decision has been made.
|
||||
private val _bootstrapping = MutableStateFlow(true)
|
||||
val bootstrapping: StateFlow<Boolean> = _bootstrapping
|
||||
@@ -72,7 +106,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 +134,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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +201,9 @@ class MpdConnectionManager(context: Context) {
|
||||
albumArtist: String?,
|
||||
) = fire { playAlbumNext(album, albumArtist) }
|
||||
|
||||
/** Replace the queue with [uris] (in order) and start playing the first. */
|
||||
fun playTracks(uris: List<String>) = fire { playTracks(uris) }
|
||||
|
||||
/** Append a single track to the end of the queue. */
|
||||
fun queueTrack(uri: String) = fire { queueTrack(uri) }
|
||||
|
||||
@@ -196,10 +236,29 @@ 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())
|
||||
|
||||
/**
|
||||
* Drop every cached cover (memory + disk) so the next load refetches from the server. Backs the
|
||||
* "Refresh artwork" action — for art that isn't mtime-keyed (album covers, and folder cover
|
||||
* files MPD serves without touching the track's mtime), this is the way to pick up changes made
|
||||
* on the server. Album-keyed art then reloads as each screen is reopened.
|
||||
*/
|
||||
fun refreshArtwork() {
|
||||
val loader = SingletonImageLoader.get(appContext)
|
||||
loader.memoryCache?.clear()
|
||||
// Disk clear touches the filesystem — keep it off the main thread.
|
||||
scope.launch(Dispatchers.IO) { loader.diskCache?.clear() }
|
||||
}
|
||||
|
||||
/** Fetch cover-art bytes for Coil (null on miss/failure). */
|
||||
suspend fun fetchArt(data: MpdArtData): ByteArray? =
|
||||
runCatching {
|
||||
@@ -210,19 +269,11 @@ class MpdConnectionManager(context: Context) {
|
||||
}
|
||||
.getOrNull()
|
||||
|
||||
/**
|
||||
* True when we own the volume: connected to a server that exposes a mixer. Used to decide
|
||||
* whether hardware volume keys drive the *server* (silently, in-app) or fall through to the
|
||||
* device's own local volume.
|
||||
*/
|
||||
val isControllingVolume: Boolean
|
||||
get() =
|
||||
connectionState.value is MpdConnectionState.Connected && status.value?.volume != null
|
||||
|
||||
/** Relative volume change (from a hardware key / VolumeProvider), accumulating. */
|
||||
/** Relative volume change (from the media session's remote VolumeProvider), accumulating. */
|
||||
fun nudgeVolume(up: Boolean) {
|
||||
val base = pendingVolume ?: status.value?.volume ?: return
|
||||
val next = (base + if (up) VOLUME_STEP else -VOLUME_STEP).coerceIn(0, 100)
|
||||
val step = volumeStep.value
|
||||
val next = (base + if (up) step else -step).coerceIn(0, 100)
|
||||
pendingVolume = next
|
||||
setVolume(next)
|
||||
}
|
||||
@@ -232,7 +283,6 @@ class MpdConnectionManager(context: Context) {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VOLUME_STEP = 5
|
||||
const val VOLUME_THROTTLE_MS = 120L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,8 @@ class PlaybackService : Service() {
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { uri ->
|
||||
artUri = uri
|
||||
artBitmap = if (uri != null) loadArtBitmap(uri) else null
|
||||
val version = manager.currentSong.value?.takeIf { it.uri == uri }?.lastModified
|
||||
artBitmap = if (uri != null) loadArtBitmap(uri, version) else null
|
||||
if (artUri == uri) {
|
||||
session.setMetadata(
|
||||
buildMetadata(manager.currentSong.value, manager.status.value)
|
||||
@@ -164,10 +165,15 @@ class PlaybackService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadArtBitmap(uri: String): Bitmap? {
|
||||
private suspend fun loadArtBitmap(
|
||||
uri: String,
|
||||
version: String?,
|
||||
): Bitmap? {
|
||||
val result =
|
||||
SingletonImageLoader.get(applicationContext)
|
||||
.execute(ImageRequest.Builder(applicationContext).data(SongArt(uri)).build())
|
||||
.execute(
|
||||
ImageRequest.Builder(applicationContext).data(SongArt(uri, version)).build()
|
||||
)
|
||||
return (result as? SuccessResult)?.image?.toBitmap()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package ca.ksamad.encore.playback
|
||||
|
||||
import android.view.KeyEvent
|
||||
|
||||
/**
|
||||
* Encapsulates hardware volume-key handling for the foreground Activity.
|
||||
*
|
||||
* While the app is focused, we want the volume keys to drive the *server* volume **silently** —
|
||||
* without the system's volume slider popping up (the app already shows its own). The trick is to
|
||||
* fully consume the key event in the Activity so it never reaches the OS volume handling that draws
|
||||
* that slider.
|
||||
*
|
||||
* When the app is backgrounded the Activity isn't in the dispatch path at all, so the
|
||||
* `MediaSession`'s `VolumeProvider` handles the keys instead — there the OS remote-volume UI
|
||||
* showing up is the expected, cast-style behaviour.
|
||||
*
|
||||
* Only volume keys we actually act on are consumed: when we're not controlling the server (e.g. the
|
||||
* connect screen), the event passes through so the device adjusts its own local volume normally.
|
||||
*/
|
||||
class VolumeKeyDispatcher(private val manager: MpdConnectionManager) {
|
||||
/**
|
||||
* Offer [event] to the volume handler. Returns `true` if it was a volume key we handled and
|
||||
* consumed (caller should then *not* pass it on), `false` to let normal dispatch continue.
|
||||
*/
|
||||
fun dispatch(event: KeyEvent): Boolean {
|
||||
val up =
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> true
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> false
|
||||
else -> return false
|
||||
}
|
||||
if (!manager.isControllingVolume) return false
|
||||
|
||||
// Nudge on each key-down (auto-repeats included, so holding keeps
|
||||
// changing); swallow the key-up too so no system UI flashes.
|
||||
if (event.action == KeyEvent.ACTION_DOWN) manager.nudgeVolume(up)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.palette.graphics.Palette
|
||||
import ca.ksamad.encore.playback.SongArt
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.request.allowHardware
|
||||
import coil3.size.Size
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// Small in-memory cache of extracted accent colours (ARGB ints), keyed by the art's cache key, so
|
||||
// revisiting a song — or a recomposition — never re-runs Palette.
|
||||
private val accentCache = LruCache<String, Int>(64)
|
||||
|
||||
/**
|
||||
* The dominant accent colour of [art]'s cover, used for the now-playing background bleed.
|
||||
*
|
||||
* Pulls a small bitmap from Coil (served from its memory/disk cache, so no extra network request),
|
||||
* runs [Palette] off the main thread, and memoises the result. Returns [fallback] until it resolves,
|
||||
* and whenever there's no art or extraction fails — so callers can just use the value directly.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberArtAccentColor(
|
||||
art: SongArt?,
|
||||
fallback: Color,
|
||||
): Color {
|
||||
val context = LocalContext.current
|
||||
return produceState(initialValue = fallback, art) {
|
||||
if (art == null) {
|
||||
value = fallback
|
||||
return@produceState
|
||||
}
|
||||
val key = "${art.uri}@${art.version.orEmpty()}"
|
||||
accentCache.get(key)?.let {
|
||||
value = Color(it)
|
||||
return@produceState
|
||||
}
|
||||
val argb =
|
||||
withContext(Dispatchers.Default) {
|
||||
runCatching {
|
||||
val loader = SingletonImageLoader.get(context)
|
||||
val result =
|
||||
loader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(art)
|
||||
.size(Size(128, 128)) // tiny: Palette downsamples anyway
|
||||
.allowHardware(false) // Palette must read pixels on the CPU
|
||||
.build()
|
||||
)
|
||||
val bitmap =
|
||||
(result as? SuccessResult)?.image?.toBitmap() ?: return@runCatching null
|
||||
val palette = Palette.from(bitmap).maximumColorCount(16).generate()
|
||||
val swatch =
|
||||
palette.dominantSwatch ?: palette.vibrantSwatch ?: palette.mutedSwatch
|
||||
swatch?.let { 0xFF000000.toInt() or it.rgb } // force opaque
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
if (argb != null) {
|
||||
accentCache.put(key, argb)
|
||||
value = Color(argb)
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
.value
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -38,6 +40,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
|
||||
@@ -47,9 +50,9 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* One album, in detail: its cover, whole-album Play / Add-to-queue actions, and the track list.
|
||||
* Tapping Play replaces the queue and starts the album (leaving this screen); swiping a track
|
||||
* queues it (right) or plays it next (left), with the same semantics as swiping an album in the
|
||||
* library.
|
||||
* Tapping Play replaces the queue and starts the album (leaving this screen); tapping a track does
|
||||
* the same but starts the album from that track onward. Swiping a track queues it (right) or plays
|
||||
* it next (left), with the same semantics as swiping an album in the library.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -65,6 +68,10 @@ fun AlbumDetailScreen(
|
||||
value = vm.loadAlbumTracks(album.name, album.albumArtist)
|
||||
}
|
||||
|
||||
// The album's audio format, taken from the tracks we already loaded (MPD reports a per-song
|
||||
// `Format` in the track listing), so no extra network call. Null until tracks arrive.
|
||||
val albumFormat = remember(tracks) { tracks?.let(::representativeFormat) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -90,6 +97,7 @@ fun AlbumDetailScreen(
|
||||
item {
|
||||
AlbumDetailHeader(
|
||||
album = album,
|
||||
format = albumFormat,
|
||||
onPlay = {
|
||||
vm.playAlbum(album.name, album.albumArtist)
|
||||
onPlay()
|
||||
@@ -101,11 +109,21 @@ fun AlbumDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// One track row, wired to its swipe actions. Reused whether the list is
|
||||
// flat or split into disc groups.
|
||||
// One track row, wired to its tap + swipe actions. Reused whether the list
|
||||
// is flat or split into disc groups.
|
||||
val trackItem: @Composable (MpdSong) -> Unit = { track ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
onPlay = {
|
||||
// Play the album from this track on: replace the queue with the
|
||||
// tapped track and everything after it (the list is already in
|
||||
// disc/track order), then start playing. Stay on the album page —
|
||||
// just flash a confirmation rather than jumping to now-playing.
|
||||
val loaded = tracks ?: return@TrackRow
|
||||
val fromHere = loaded.dropWhile { it.uri != track.uri }.map { it.uri }
|
||||
vm.playTracks(fromHere)
|
||||
flash("Playing “${track.title ?: track.uri}”")
|
||||
},
|
||||
onQueue = {
|
||||
vm.queueTrack(track.uri)
|
||||
flash("Added “${track.title ?: track.uri}” to the queue")
|
||||
@@ -162,10 +180,11 @@ fun AlbumDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Cover, title/artist, and the two whole-album action buttons. */
|
||||
/** Cover, title/artist, audio-format pills, and the two whole-album action buttons. */
|
||||
@Composable
|
||||
private fun AlbumDetailHeader(
|
||||
album: MpdAlbum,
|
||||
format: String?,
|
||||
onPlay: () -> Unit,
|
||||
onQueue: () -> Unit,
|
||||
) {
|
||||
@@ -176,12 +195,13 @@ private fun AlbumDetailHeader(
|
||||
ArtImage(
|
||||
model = AlbumArt(album.name, album.albumArtist),
|
||||
iconSize = 72.dp,
|
||||
modifier = Modifier.size(220.dp).clip(RoundedCornerShape(12.dp)),
|
||||
modifier = Modifier.fillMaxWidth(0.85f).aspectRatio(1f).clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Text(
|
||||
album.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -190,10 +210,12 @@ private fun AlbumDetailHeader(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
AudioFormatPills(pills = audioFormatPills(format), modifier = Modifier.padding(top = 10.dp))
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -212,18 +234,21 @@ private fun AlbumDetailHeader(
|
||||
}
|
||||
|
||||
/**
|
||||
* A single track row. Swiping right queues the track, swiping left plays it next — the same gesture
|
||||
* as the album library, scoped to this one track. The leading slot shows the track number (all
|
||||
* tracks share the album's cover, so a per-track thumbnail would be redundant here).
|
||||
* A single track row. Tapping plays the album from this track onward (replacing the queue); swiping
|
||||
* right queues just this track and swiping left plays it next — the same gestures as the album
|
||||
* library, scoped to this one track. The leading slot shows the track number (all tracks share the
|
||||
* album's cover, so a per-track thumbnail would be redundant here).
|
||||
*/
|
||||
@Composable
|
||||
private fun TrackRow(
|
||||
track: MpdSong,
|
||||
onPlay: () -> Unit,
|
||||
onQueue: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
) {
|
||||
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onPlay),
|
||||
leadingContent = {
|
||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
@@ -263,3 +288,16 @@ private fun trackNumberOf(track: MpdSong): Int? =
|
||||
/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */
|
||||
private fun discNumberOf(track: MpdSong): Int =
|
||||
track.disc?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 1
|
||||
|
||||
/**
|
||||
* The album's representative audio format: the most common per-track `Format`
|
||||
* (`samplerate:bits:channels`) among the loaded tracks. Null when no track reports one. Using the
|
||||
* mode keeps a stray transcoded/hidden track from misrepresenting the album.
|
||||
*/
|
||||
private fun representativeFormat(tracks: List<MpdSong>): String? =
|
||||
tracks
|
||||
.mapNotNull { it.format }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
.maxByOrNull { it.value }
|
||||
?.key
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
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.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -44,9 +53,12 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.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
|
||||
@@ -59,9 +71,16 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun AlbumsScreen(
|
||||
vm: PlayerViewModel,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
listState: LazyListState,
|
||||
gridState: LazyGridState,
|
||||
onBack: () -> Unit,
|
||||
onOpenAlbum: (MpdAlbum) -> Unit,
|
||||
) {
|
||||
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
||||
val gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
|
||||
|
||||
// null = still loading.
|
||||
val albums by
|
||||
produceState<List<MpdAlbum>?>(initialValue = null) {
|
||||
@@ -72,9 +91,10 @@ fun AlbumsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Client-side search over the already-loaded index (album title + album artist).
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
// The search text is hoisted (owned by EncoreApp) so it survives drilling into
|
||||
// an album and coming back. Whether the search bar is *open* is local, but it
|
||||
// starts open whenever there's a restored query so returning re-shows the search.
|
||||
var searching by remember { mutableStateOf(query.isNotEmpty()) }
|
||||
|
||||
// Transient confirmation for the swipe actions (add-to-queue / play-next).
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -116,7 +136,7 @@ fun AlbumsScreen(
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (searching) {
|
||||
AlbumSearchField(query = query, onQueryChange = { query = it })
|
||||
AlbumSearchField(query = query, onQueryChange = onQueryChange)
|
||||
} else {
|
||||
Text(filtered?.let { "Albums (${it.size})" } ?: "Albums")
|
||||
}
|
||||
@@ -126,7 +146,7 @@ fun AlbumsScreen(
|
||||
onClick = {
|
||||
if (searching) {
|
||||
searching = false
|
||||
query = ""
|
||||
onQueryChange("")
|
||||
} else {
|
||||
onBack()
|
||||
}
|
||||
@@ -137,7 +157,7 @@ fun AlbumsScreen(
|
||||
},
|
||||
actions = {
|
||||
if (searching) {
|
||||
IconButton(onClick = { query = "" }, enabled = query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }, enabled = query.isNotEmpty()) {
|
||||
Icon(Icons.Filled.Clear, contentDescription = "Clear search")
|
||||
}
|
||||
} else if (current != null) {
|
||||
@@ -173,8 +193,23 @@ fun AlbumsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
viewMode == AlbumViewMode.Grid -> {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(gridColumns),
|
||||
state = gridState,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
contentPadding = PaddingValues(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
||||
AlbumGridCell(album = album, onTap = { onOpenAlbum(album) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||
LazyColumn(state = listState, modifier = Modifier.padding(innerPadding)) {
|
||||
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
||||
SwipeableAlbumRow(
|
||||
album = album,
|
||||
@@ -229,8 +264,46 @@ private fun SwipeableAlbumRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard;
|
||||
* the surrounding [TopAppBar] handles clearing/closing.
|
||||
* 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
|
||||
* only on a fresh (empty) open — when it reappears already populated (e.g. restored after returning
|
||||
* from an album) it shows the query and results without stealing focus. The surrounding [TopAppBar]
|
||||
* handles clearing/closing.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -259,5 +332,6 @@ private fun AlbumSearchField(
|
||||
),
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
// Only grab focus for a fresh search; a restored (non-empty) query shouldn't pop the keyboard.
|
||||
LaunchedEffect(Unit) { if (query.isEmpty()) focusRequester.requestFocus() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A row of little rounded pills describing an audio format — sample rate, bit depth, and channel
|
||||
* layout (e.g. "44.1 kHz", "16-bit", "Stereo"). Shared by now-playing (the live stream) and album
|
||||
* detail (an album's stored format). Renders nothing when [pills] is empty.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AudioFormatPills(
|
||||
pills: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (pills.isEmpty()) return
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
pills.forEach { pill ->
|
||||
Text(
|
||||
text = pill,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an MPD audio-format string, `samplerate:bits:channels` (e.g. `"44100:16:2"`), into pill
|
||||
* labels. The middle token can be `f` (float) or `dsd` instead of a bit depth. Only well-formed
|
||||
* parts appear; a null/garbage input yields an empty list.
|
||||
*/
|
||||
fun audioFormatPills(format: String?): List<String> {
|
||||
val parts = format?.split(":")?.takeIf { it.size >= 3 } ?: return emptyList()
|
||||
return buildList {
|
||||
parts[0].toIntOrNull()?.let { add(formatSampleRate(it)) }
|
||||
formatSampleFormat(parts[1])?.let { add(it) }
|
||||
formatChannels(parts[2])?.let { add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatSampleRate(hz: Int): String {
|
||||
val khz = hz / 1000.0
|
||||
val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz)
|
||||
return "$value kHz"
|
||||
}
|
||||
|
||||
private fun formatSampleFormat(token: String): String? =
|
||||
when (token) {
|
||||
"f" -> "Float"
|
||||
"dsd" -> "DSD"
|
||||
else -> token.toIntOrNull()?.let { "$it-bit" }
|
||||
}
|
||||
|
||||
private fun formatChannels(token: String): String? =
|
||||
when (token.toIntOrNull()) {
|
||||
null -> null
|
||||
1 -> "Mono"
|
||||
2 -> "Stereo"
|
||||
else -> "$token ch"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
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,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
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 +30,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
|
||||
@@ -55,6 +65,14 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
||||
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
||||
// When set (within the Albums overlay), the album detail screen is shown.
|
||||
var detailAlbum by rememberSaveable(stateSaver = AlbumSaver) { mutableStateOf<MpdAlbum?>(null) }
|
||||
// Owned here (not in AlbumsScreen) so the search text survives drilling into an
|
||||
// album and back — AlbumsScreen leaves composition while the detail is shown.
|
||||
var albumSearch by rememberSaveable { mutableStateOf("") }
|
||||
// Same reasoning for the library's scroll position (one per layout), so backing
|
||||
// out of an album returns you to where you were. rememberLazy*State is itself
|
||||
// saveable, so these also survive rotation/process death.
|
||||
val albumListState = rememberLazyListState()
|
||||
val albumGridState = rememberLazyGridState()
|
||||
|
||||
// Overlays only make sense over the player; leaving it (e.g. after a
|
||||
// reset/disconnect) drops us back to the normal screen flow.
|
||||
@@ -65,6 +83,13 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Forget the album search once we leave the library entirely (to the player or
|
||||
// another overlay). The detail screen keeps overlay == Albums, so browsing into
|
||||
// an album and back preserves it.
|
||||
LaunchedEffect(overlay) {
|
||||
if (overlay != PlayerOverlay.Albums) albumSearch = ""
|
||||
}
|
||||
|
||||
when (val s = screen) {
|
||||
is AppScreen.Loading -> {
|
||||
LoadingScreen()
|
||||
@@ -75,6 +100,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 })
|
||||
@@ -95,6 +132,10 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
||||
} else {
|
||||
AlbumsScreen(
|
||||
vm,
|
||||
query = albumSearch,
|
||||
onQueryChange = { albumSearch = it },
|
||||
listState = albumListState,
|
||||
gridState = albumGridState,
|
||||
onBack = { overlay = PlayerOverlay.None },
|
||||
onOpenAlbum = { detailAlbum = it },
|
||||
)
|
||||
@@ -145,6 +186,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 +220,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(),
|
||||
) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,11 +13,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Cast
|
||||
import androidx.compose.material.icons.filled.LibraryMusic
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
@@ -26,9 +30,8 @@ import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -45,15 +48,53 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||
import ca.ksamad.encore.mpd.model.PlayerState
|
||||
import ca.ksamad.encore.playback.SongArt
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** Largest the flexible album art is allowed to grow, so it doesn't sprawl on tablets/foldables. */
|
||||
private val ART_MAX_SIZE = 480.dp
|
||||
|
||||
/**
|
||||
* Minimum usable height at which we trust the whole screen fits without scrolling (and switch to the
|
||||
* flexible, art-fills-the-slack layout). Below this — landscape, small phones — we scroll instead so
|
||||
* the lower controls never get clipped.
|
||||
*/
|
||||
private val FLEX_MIN_HEIGHT = 740.dp
|
||||
|
||||
// --- Album-art colour-bleed tuning ---
|
||||
// A 3-stop radial fade (centre → mid → base) with no flat plateau, so the colour fades continuously
|
||||
// out from the cover. The strengths are how far the accent is blended toward the base (0 = base,
|
||||
// 1 = full accent).
|
||||
/** Accent blend at the cover centre (strongest). */
|
||||
private const val BLEED_STRENGTH = 0.55f
|
||||
|
||||
/** Accent blend at [BLEED_MID_STOP] — the tail of the fade. */
|
||||
private const val BLEED_MID_STRENGTH = 0.22f
|
||||
|
||||
/** Radius fraction at which the mid colour sits (shapes the falloff curve). */
|
||||
private const val BLEED_MID_STOP = 0.5f
|
||||
|
||||
/** Gradient radius as a multiple of the cover's larger side — larger = more gradual, wider spread. */
|
||||
private const val BLEED_RADIUS_FACTOR = 1.9f
|
||||
|
||||
/** Crossfade duration (ms) when the accent changes on a new song. */
|
||||
private const val BLEED_ANIM_MS = 700
|
||||
|
||||
/**
|
||||
* The now-playing screen: current track, a live seek bar, transport controls, volume, and the
|
||||
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
|
||||
@@ -68,152 +109,255 @@ fun NowPlayingScreen(
|
||||
) {
|
||||
val status by vm.status.collectAsStateWithLifecycle()
|
||||
val song by vm.currentSong.collectAsStateWithLifecycle()
|
||||
val serverHost by vm.serverHost.collectAsStateWithLifecycle()
|
||||
var showDisconnectConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
// --- Album-art colour bleed ------------------------------------------------
|
||||
// Pull the cover's dominant colour and radiate it from the cover's on-screen position so the
|
||||
// artwork looks like it bleeds into the page. Centre/radius come from the art's measured bounds
|
||||
// (captureCover); the gradient draws edge-to-edge behind everything (before systemBarsPadding).
|
||||
val base = MaterialTheme.colorScheme.background
|
||||
val accent =
|
||||
rememberArtAccentColor(song?.let { SongArt(it.uri, it.lastModified) }, fallback = base)
|
||||
val animatedAccent by animateColorAsState(accent, tween(BLEED_ANIM_MS), label = "artAccent")
|
||||
var coverCenter by remember { mutableStateOf(Offset.Unspecified) }
|
||||
var coverRadius by remember { mutableFloatStateOf(0f) }
|
||||
fun captureCover(coords: LayoutCoordinates) {
|
||||
val pos = coords.positionInRoot()
|
||||
val w = coords.size.width.toFloat()
|
||||
val h = coords.size.height.toFloat()
|
||||
coverCenter = Offset(pos.x + w / 2f, pos.y + h / 2f)
|
||||
coverRadius = maxOf(w, h) * BLEED_RADIUS_FACTOR
|
||||
}
|
||||
|
||||
// On a tall enough screen, lay everything out without scrolling and let the album art
|
||||
// flex into the leftover vertical space (capped at ART_MAX_SIZE) — bigger displays show
|
||||
// bigger art, smaller ones just shrink it. On a short viewport (landscape, small phones)
|
||||
// fall back to a scrolling column with a fixed art size so the lower controls stay reachable.
|
||||
val scrollState = rememberScrollState()
|
||||
BoxWithConstraints(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.drawBehind {
|
||||
val c = if (coverCenter.isSpecified) coverCenter else center
|
||||
val r = if (coverRadius > 0f) coverRadius else size.minDimension
|
||||
val centerColor = lerp(base, animatedAccent, BLEED_STRENGTH)
|
||||
val midColor = lerp(base, animatedAccent, BLEED_MID_STRENGTH)
|
||||
drawRect(
|
||||
Brush.radialGradient(
|
||||
0f to centerColor,
|
||||
BLEED_MID_STOP to midColor,
|
||||
1f to base,
|
||||
center = c,
|
||||
radius = r,
|
||||
)
|
||||
)
|
||||
}
|
||||
.systemBarsPadding()
|
||||
) {
|
||||
// --- Top bar: library + settings ------------------------------------
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
IconButton(onClick = onOpenQueue) {
|
||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
|
||||
val flexible = maxHeight >= FLEX_MIN_HEIGHT
|
||||
// top = 0: the icon buttons carry their own internal padding, and systemBarsPadding
|
||||
// already clears the status bar — any extra top padding just pushes them down.
|
||||
val columnModifier =
|
||||
if (flexible) {
|
||||
Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, bottom = 12.dp)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||
}
|
||||
IconButton(onClick = onOpenLibrary) {
|
||||
Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums")
|
||||
|
||||
Column(modifier = columnModifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// --- Top bar: disconnect (left) + queue/library/settings (right) ----
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = { showDisconnectConfirm = true }) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onOpenQueue) {
|
||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
|
||||
}
|
||||
IconButton(onClick = onOpenLibrary) {
|
||||
Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums")
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Album art -------------------------------------------------------
|
||||
ArtImage(
|
||||
model = song?.uri?.let { SongArt(it) },
|
||||
iconSize = 96.dp,
|
||||
modifier =
|
||||
Modifier.padding(vertical = 16.dp).size(240.dp).clip(RoundedCornerShape(16.dp)),
|
||||
)
|
||||
|
||||
// --- Track metadata --------------------------------------------------
|
||||
Text(
|
||||
text = song?.title ?: song?.uri ?: "Nothing playing",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = song?.artist ?: "—",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = song?.album ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Descriptive metadata (release year · genre), when the tags are present.
|
||||
val descriptors =
|
||||
listOfNotNull(
|
||||
song?.date?.let(::releaseYear),
|
||||
song?.genre?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
if (descriptors.isNotEmpty()) {
|
||||
Text(
|
||||
text = descriptors.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
AudioPropertyPills(status = status)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// --- Transport -------------------------------------------------------
|
||||
val playing = status?.state == PlayerState.PLAY
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) {
|
||||
Icon(
|
||||
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (playing) "Pause" else "Play",
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.SkipNext,
|
||||
contentDescription = "Next",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// CastIndicator(host = serverHost)
|
||||
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// --- Playback options ------------------------------------------------
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
FilterChip(
|
||||
selected = status?.repeat == true,
|
||||
onClick = { vm.setRepeat(status?.repeat != true) },
|
||||
label = { Text("Repeat") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Repeat,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
// --- Album art ---------------------------------------------------
|
||||
val artModel = song?.let { SongArt(it.uri, it.lastModified) }
|
||||
if (flexible) {
|
||||
// Fill the leftover height, kept square and capped by width/height/max.
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val side = minOf(maxWidth, maxHeight, ART_MAX_SIZE)
|
||||
ArtImage(
|
||||
model = artModel,
|
||||
iconSize = 96.dp,
|
||||
modifier =
|
||||
Modifier.size(side)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.onGloballyPositioned(::captureCover),
|
||||
)
|
||||
},
|
||||
)
|
||||
FilterChip(
|
||||
selected = status?.random == true,
|
||||
onClick = { vm.setRandom(status?.random != true) },
|
||||
label = { Text("Shuffle") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Shuffle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ArtImage(
|
||||
model = artModel,
|
||||
iconSize = 96.dp,
|
||||
modifier =
|
||||
Modifier.padding(vertical = 16.dp)
|
||||
.fillMaxWidth(0.9f)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.onGloballyPositioned(::captureCover),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
TextButton(onClick = vm::disconnect) {
|
||||
Text("Disconnect")
|
||||
PlaybackDetails(vm = vm, status = status, song = song)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDisconnectConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDisconnectConfirm = false },
|
||||
title = { Text("Disconnect?") },
|
||||
text = { Text("Disconnect from the server and return to the connect screen.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDisconnectConfirm = false
|
||||
vm.disconnect()
|
||||
}
|
||||
) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything below the album art: track metadata, the seek bar, transport, volume, and the option
|
||||
* chips. Emitted straight into the caller's centered [Column] so it's shared by both the flexible
|
||||
* (no-scroll) and scrolling layouts.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaybackDetails(
|
||||
vm: PlayerViewModel,
|
||||
status: MpdStatus?,
|
||||
song: MpdSong?,
|
||||
) {
|
||||
// --- Track metadata --------------------------------------------------
|
||||
Text(
|
||||
text = song?.title ?: song?.uri ?: "Nothing playing",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = song?.artist ?: "—",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = song?.album ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
// Descriptive metadata (release year · genre), when the tags are present.
|
||||
val descriptors =
|
||||
listOfNotNull(
|
||||
song?.date?.let(::releaseYear),
|
||||
song?.genre?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
if (descriptors.isNotEmpty()) {
|
||||
Text(
|
||||
text = descriptors.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
AudioPropertyPills(status = status)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// --- Transport (repeat · prev · play/pause · next · shuffle) ---------
|
||||
// Repeat and shuffle are icon-only toggles — their icons are well-known, so no
|
||||
// label is needed. They tint to the primary colour when on, muted when off.
|
||||
val playing = status?.state == PlayerState.PLAY
|
||||
val repeatOn = status?.repeat == true
|
||||
val shuffleOn = status?.random == true
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
IconButton(onClick = { vm.setRepeat(!repeatOn) }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Repeat,
|
||||
contentDescription = if (repeatOn) "Repeat on" else "Repeat off",
|
||||
tint =
|
||||
if (repeatOn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.SkipPrevious,
|
||||
contentDescription = "Previous",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) {
|
||||
Icon(
|
||||
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||
contentDescription = if (playing) "Pause" else "Play",
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.SkipNext,
|
||||
contentDescription = "Next",
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { vm.setRandom(!shuffleOn) }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Shuffle,
|
||||
contentDescription = if (shuffleOn) "Shuffle on" else "Shuffle off",
|
||||
tint =
|
||||
if (shuffleOn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,74 +412,18 @@ private fun SeekBar(
|
||||
* (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's
|
||||
* no format info (e.g. stopped).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun AudioPropertyPills(status: MpdStatus?) {
|
||||
val pills = remember(status?.audio, status?.bitrate) { audioPropertyPills(status) }
|
||||
if (pills.isEmpty()) return
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
pills.forEach { pill ->
|
||||
Text(
|
||||
text = pill,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
val pills =
|
||||
remember(status?.audio, status?.bitrate) {
|
||||
buildList {
|
||||
addAll(audioFormatPills(status?.audio))
|
||||
status?.bitrate?.takeIf { it > 0 }?.let { add("$it kbps") }
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioFormatPills(pills = pills, modifier = Modifier.padding(top = 10.dp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the audio-format pill labels from a status snapshot. MPD's `audio` field is
|
||||
* `samplerate:bits:channels` (e.g. `"44100:16:2"`), where the middle token can be `f` (float) or
|
||||
* `dsd` rather than a bit depth. Only well-formed parts appear.
|
||||
*/
|
||||
private fun audioPropertyPills(status: MpdStatus?): List<String> {
|
||||
status ?: return emptyList()
|
||||
val pills = mutableListOf<String>()
|
||||
|
||||
status.audio
|
||||
?.split(":")
|
||||
?.takeIf { it.size >= 3 }
|
||||
?.let { parts ->
|
||||
parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) }
|
||||
formatSampleFormat(parts[1])?.let { pills.add(it) }
|
||||
formatChannels(parts[2])?.let { pills.add(it) }
|
||||
}
|
||||
status.bitrate?.takeIf { it > 0 }?.let { pills.add("$it kbps") }
|
||||
|
||||
return pills
|
||||
}
|
||||
|
||||
private fun formatSampleRate(hz: Int): String {
|
||||
val khz = hz / 1000.0
|
||||
val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz)
|
||||
return "$value kHz"
|
||||
}
|
||||
|
||||
private fun formatSampleFormat(token: String): String? =
|
||||
when (token) {
|
||||
"f" -> "Float"
|
||||
"dsd" -> "DSD"
|
||||
else -> token.toIntOrNull()?.let { "$it-bit" }
|
||||
}
|
||||
|
||||
private fun formatChannels(token: String): String? =
|
||||
when (token.toIntOrNull()) {
|
||||
null -> null
|
||||
1 -> "Mono"
|
||||
2 -> "Stereo"
|
||||
else -> "$token ch"
|
||||
}
|
||||
|
||||
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
|
||||
@Composable
|
||||
private fun CastIndicator(host: String?) {
|
||||
@@ -372,7 +460,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,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
val currentSong = manager.currentSong
|
||||
val serverHost = manager.serverHost
|
||||
val settings = manager.settings
|
||||
val albumViewMode = manager.albumViewMode
|
||||
val volumeStep = manager.volumeStep
|
||||
val gridColumns = manager.gridColumns
|
||||
|
||||
val screen: StateFlow<AppScreen> =
|
||||
combine(
|
||||
@@ -64,7 +68,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 +91,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
fun setConsume(on: Boolean) = manager.setConsume(on)
|
||||
|
||||
fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode)
|
||||
|
||||
fun setVolumeStep(step: Int) = manager.setVolumeStep(step)
|
||||
|
||||
fun setGridColumns(columns: Int) = manager.setGridColumns(columns)
|
||||
|
||||
fun playAlbum(
|
||||
album: String,
|
||||
albumArtist: String?,
|
||||
@@ -101,6 +112,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
albumArtist: String?,
|
||||
) = manager.playAlbumNext(album, albumArtist)
|
||||
|
||||
fun playTracks(uris: List<String>) = manager.playTracks(uris)
|
||||
|
||||
fun queueTrack(uri: String) = manager.queueTrack(uri)
|
||||
|
||||
fun playTrackNext(uri: String) = manager.playTrackNext(uri)
|
||||
@@ -112,6 +125,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
suspend fun loadAlbums() = manager.loadAlbums()
|
||||
|
||||
fun updateDatabase() = manager.updateDatabase()
|
||||
|
||||
fun rescanDatabase() = manager.rescanDatabase()
|
||||
|
||||
fun refreshArtwork() = manager.refreshArtwork()
|
||||
|
||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
||||
|
||||
fun 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
|
||||
@@ -50,6 +50,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||
import ca.ksamad.encore.playback.AlbumArt
|
||||
import ca.ksamad.encore.playback.MpdArtData
|
||||
import ca.ksamad.encore.playback.SongArt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -220,6 +222,17 @@ fun QueueScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Art model for a queue row. Keying by album — not the per-track URI — means every track of the
|
||||
* same album resolves to one cache entry, so a 12-track album is fetched from the server once
|
||||
* instead of twelve times, and reuses whatever the album library already cached. Falls back to the
|
||||
* track's own art only for entries with no album tag (e.g. a loose file or a stream).
|
||||
*/
|
||||
private fun queueArt(song: MpdSong): MpdArtData =
|
||||
song.album
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { AlbumArt(it, song.albumArtist) } ?: SongArt(song.uri, song.lastModified)
|
||||
|
||||
/** A single queue entry — art, title/artist, and a duration or now-playing badge. */
|
||||
@Composable
|
||||
private fun QueueRow(
|
||||
@@ -237,7 +250,7 @@ private fun QueueRow(
|
||||
},
|
||||
leadingContent = {
|
||||
ArtImage(
|
||||
model = SongArt(song.uri),
|
||||
model = queueArt(song),
|
||||
iconSize = 20.dp,
|
||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
@@ -256,7 +269,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,21 +1,30 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
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 +32,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 +46,20 @@ 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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.encore.data.AlbumViewMode
|
||||
import ca.ksamad.encore.data.SettingsRepository
|
||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
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 +74,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 +103,8 @@ fun SettingsScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier =
|
||||
@@ -119,6 +149,159 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
// How far the hardware volume keys move the server volume per press.
|
||||
val volumeStep by vm.volumeStep.collectAsStateWithLifecycle()
|
||||
ListItem(
|
||||
headlineContent = { Text("Volume step") },
|
||||
supportingContent = { Text("How much each volume-key press changes the volume") },
|
||||
trailingContent = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
onClick = { vm.setVolumeStep(volumeStep - 1) },
|
||||
enabled = volumeStep > SettingsRepository.MIN_VOLUME_STEP,
|
||||
) {
|
||||
Icon(Icons.Filled.Remove, contentDescription = "Decrease volume step")
|
||||
}
|
||||
Text(
|
||||
"$volumeStep%",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.width(40.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { vm.setVolumeStep(volumeStep + 1) },
|
||||
enabled = volumeStep < SettingsRepository.MAX_VOLUME_STEP,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Increase volume step")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
"Library",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 20.dp, bottom = 4.dp),
|
||||
)
|
||||
val albumViewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
||||
ListItem(
|
||||
headlineContent = { Text("Album view") },
|
||||
supportingContent = { Text("How the albums library is laid out") },
|
||||
trailingContent = {
|
||||
SingleChoiceSegmentedButtonRow {
|
||||
AlbumViewMode.entries.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = albumViewMode == mode,
|
||||
onClick = { vm.setAlbumViewMode(mode) },
|
||||
shape =
|
||||
SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = AlbumViewMode.entries.size,
|
||||
),
|
||||
icon = {},
|
||||
) {
|
||||
Text(
|
||||
when (mode) {
|
||||
AlbumViewMode.List -> "List"
|
||||
AlbumViewMode.Grid -> "Grid"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Column count only matters for the grid layout, so only offer it there.
|
||||
if (albumViewMode == AlbumViewMode.Grid) {
|
||||
val gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
|
||||
val columnOptions =
|
||||
SettingsRepository.MIN_GRID_COLUMNS..SettingsRepository.MAX_GRID_COLUMNS
|
||||
ListItem(
|
||||
headlineContent = { Text("Grid columns") },
|
||||
supportingContent = { Text("Number of albums per row") },
|
||||
trailingContent = {
|
||||
SingleChoiceSegmentedButtonRow {
|
||||
columnOptions.forEachIndexed { index, columns ->
|
||||
SegmentedButton(
|
||||
selected = gridColumns == columns,
|
||||
onClick = { vm.setGridColumns(columns) },
|
||||
shape =
|
||||
SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = columnOptions.count(),
|
||||
),
|
||||
icon = {},
|
||||
) {
|
||||
Text(columns.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Ask the server to rescan its music sources. Tap runs a normal (mtime-based)
|
||||
// update; the overflow offers a full rescan that also re-reads unchanged files.
|
||||
// While a scan runs, `status.updating` drives the "Refreshing…" state.
|
||||
val refreshing = status?.updating == true
|
||||
ListItem(
|
||||
modifier =
|
||||
Modifier.clickable(enabled = !refreshing) {
|
||||
vm.updateDatabase()
|
||||
flash("Library refresh started")
|
||||
},
|
||||
headlineContent = { Text("Refresh library") },
|
||||
supportingContent = {
|
||||
Text(if (refreshing) "Refreshing…" else "Scan for new and changed files")
|
||||
},
|
||||
trailingContent = {
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
Box {
|
||||
IconButton(onClick = { refreshMenuOpen = true }) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
contentDescription = "More refresh options",
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = refreshMenuOpen,
|
||||
onDismissRequest = { refreshMenuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Full rescan") },
|
||||
onClick = {
|
||||
refreshMenuOpen = false
|
||||
vm.rescanDatabase()
|
||||
flash("Full rescan started")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Drop Encore's cached covers so corrected art on the server shows up. Most
|
||||
// re-tagged/re-imported tracks refresh on their own (their mtime changes the
|
||||
// art's cache key); this is for the rest — album covers and bare cover.jpg
|
||||
// swaps that don't bump a track's mtime.
|
||||
ListItem(
|
||||
modifier =
|
||||
Modifier.clickable {
|
||||
vm.refreshArtwork()
|
||||
flash("Artwork cache cleared — covers will reload")
|
||||
},
|
||||
headlineContent = { Text("Refresh artwork") },
|
||||
supportingContent = { Text("Reload cover art from the server") },
|
||||
trailingContent = {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
"Server statistics",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
|
||||
@@ -1,82 +1,122 @@
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue]
|
||||
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
|
||||
* A swipe only fires when the finger is still moving horizontally faster than this on release — a
|
||||
* deliberate flick. A slow drag (e.g. the small sideways drift while scrolling the list up and down)
|
||||
* settles back without triggering, which is the whole point.
|
||||
*/
|
||||
private val SWIPE_VELOCITY_THRESHOLD = 450.dp // per second
|
||||
|
||||
/** How far a row can be dragged sideways (enough to show the reveal, then it clamps). */
|
||||
private val SWIPE_MAX_REVEAL = 96.dp
|
||||
|
||||
/** Minimum sideways travel before a flick counts, so a fast stationary twitch can't trigger it. */
|
||||
private val SWIPE_MIN_DISTANCE = 24.dp
|
||||
|
||||
/**
|
||||
* Wraps [content] in the app's shared "queue swipe" affordance: flick right to [onAddToQueue]
|
||||
* (append), flick left to [onPlayNext] (insert after the current track). Used for both album rows
|
||||
* and individual tracks so the gesture and its meaning stay identical wherever it appears.
|
||||
*
|
||||
* Both are one-shot actions, not deletions, so the row always springs back and stays in the list —
|
||||
* [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once settled.
|
||||
* Unlike a dismiss-style swipe, committing is gated on **release velocity**, not distance — so
|
||||
* accidental sideways movement while scrolling vertically slides a little and springs back instead
|
||||
* of firing. [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once
|
||||
* settled at rest.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun QueueSwipeRow(
|
||||
onAddToQueue: () -> Unit,
|
||||
onPlayNext: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val state =
|
||||
rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { target ->
|
||||
when (target) {
|
||||
SwipeToDismissBoxValue.StartToEnd -> {
|
||||
onAddToQueue()
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val offsetX = remember { Animatable(0f) }
|
||||
|
||||
SwipeToDismissBoxValue.EndToStart -> {
|
||||
onPlayNext()
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val velocityThresholdPx = with(density) { SWIPE_VELOCITY_THRESHOLD.toPx() }
|
||||
val maxRevealPx = with(density) { SWIPE_MAX_REVEAL.toPx() }
|
||||
val minDistancePx = with(density) { SWIPE_MIN_DISTANCE.toPx() }
|
||||
|
||||
SwipeToDismissBoxValue.Settled -> {}
|
||||
}
|
||||
false // Never settle to dismissed — snap back and keep the row.
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.pointerInput(Unit) {
|
||||
val tracker = VelocityTracker()
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { tracker.resetTracking() },
|
||||
onHorizontalDrag = { change, dragAmount ->
|
||||
tracker.addPosition(change.uptimeMillis, change.position)
|
||||
val target = (offsetX.value + dragAmount).coerceIn(-maxRevealPx, maxRevealPx)
|
||||
scope.launch { offsetX.snapTo(target) }
|
||||
change.consume()
|
||||
},
|
||||
onDragEnd = {
|
||||
val velocity = tracker.calculateVelocity().x
|
||||
val offset = offsetX.value
|
||||
// A deliberate flick: fast enough, far enough, and the flick and the
|
||||
// drag point the same way (so a bounce-back release doesn't count).
|
||||
val committed =
|
||||
abs(velocity) >= velocityThresholdPx &&
|
||||
abs(offset) >= minDistancePx &&
|
||||
(velocity > 0f) == (offset > 0f)
|
||||
if (committed) {
|
||||
if (offset > 0f) onAddToQueue() else onPlayNext()
|
||||
}
|
||||
scope.launch { offsetX.animateTo(0f) }
|
||||
},
|
||||
onDragCancel = { scope.launch { offsetX.animateTo(0f) } },
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SwipeToDismissBox(
|
||||
state = state,
|
||||
backgroundContent = { SwipeActionBackground(state.dismissDirection) },
|
||||
) {
|
||||
content()
|
||||
SwipeActionBackground(offset = offsetX.value, modifier = Modifier.matchParentSize())
|
||||
Box(modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (swipe
|
||||
* right) and a "Play next" hint on the trailing edge (swipe left). Renders empty while the row is
|
||||
* settled.
|
||||
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (drag
|
||||
* right, [offset] > 0) and a "Play next" hint on the trailing edge (drag left, [offset] < 0).
|
||||
* Renders empty while the row is settled at rest.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
||||
if (direction == SwipeToDismissBoxValue.Settled) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
private fun SwipeActionBackground(
|
||||
offset: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (offset == 0f) {
|
||||
Box(modifier)
|
||||
return
|
||||
}
|
||||
|
||||
val queueing = direction == SwipeToDismissBoxValue.StartToEnd
|
||||
val queueing = offset > 0f
|
||||
val container =
|
||||
if (queueing) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
@@ -93,7 +133,7 @@ private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
||||
val label = if (queueing) "Add to queue" else "Play next"
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(container).padding(horizontal = 24.dp),
|
||||
modifier.background(container).padding(horizontal = 24.dp),
|
||||
contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -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(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.
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 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
|
||||
@@ -13,6 +13,7 @@ datastore = "1.1.1" # Jetpack DataStore (persisting connection sett
|
||||
media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume
|
||||
coil = "3.0.4" # Coil: image loading + memory/disk caching for album art
|
||||
# (pinned to a 3.x that targets compileSdk 35; 3.5 needs 36)
|
||||
palette = "1.0.0" # AndroidX Palette: extract cover-art colours for the bleed effect
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
@@ -32,6 +33,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -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
|
||||