Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2ae6adf8
|
||
|
|
163fa6c87e
|
||
|
|
62383accf7
|
||
|
|
22d570e191
|
||
|
|
330de1eda9
|
||
|
|
bbc92278f4
|
||
|
|
d9b665cdca
|
||
|
|
3d57fca003
|
||
|
|
de13d3b36b
|
||
|
|
665df77fc4
|
||
|
|
3abd2c4c96
|
@@ -7,6 +7,11 @@ build/
|
|||||||
*.aab
|
*.aab
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
|
# Release signing — NEVER commit the key or its passwords
|
||||||
|
keystore.properties
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2026 Karim Abdul-Samad
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Encore
|
# Encore
|
||||||
|
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/icon.png" width="120" align="right" alt="Encore app icon">
|
||||||
|
|
||||||
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
||||||
tested with **Nix**.
|
tested with **Nix**.
|
||||||
|
|
||||||
@@ -7,6 +9,12 @@ Jetpack Compose *is* the modern Android UI framework — declarative Kotlin UI t
|
|||||||
replaced the old XML layouts. Material 3 is Google's current design system, wired up here
|
replaced the old XML layouts. Material 3 is Google's current design system, wired up here
|
||||||
with dynamic (wallpaper-based) color on Android 12+.
|
with dynamic (wallpaper-based) color on Android 12+.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| Now Playing | Library | Queue | Settings |
|
||||||
|
|:-----------:|:-------:|:-----:|:--------:|
|
||||||
|
| <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="180" alt="Now Playing"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="180" alt="Album grid"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="180" alt="Queue"> | <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="180" alt="Settings"> |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -101,6 +109,10 @@ downloads through a man-in-the-middle proxy and storing their hashes — see
|
|||||||
real Android device/DAP over USB (and the NixOS `adb` notes).
|
real Android device/DAP over USB (and the NixOS `adb` notes).
|
||||||
- [`docs/TODO.md`](docs/TODO.md) — near-term roadmap / known bugs.
|
- [`docs/TODO.md`](docs/TODO.md) — near-term roadmap / known bugs.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Encore is licensed under the [Apache License 2.0](LICENSE).
|
||||||
|
|
||||||
## Version notes
|
## Version notes
|
||||||
|
|
||||||
Pinned in `gradle/libs.versions.toml`: AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01,
|
Pinned in `gradle/libs.versions.toml`: AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01,
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release signing is loaded from keystore.properties, which exists only on a machine
|
||||||
|
// that owns the signing key (never committed — see .gitignore). F-Droid and CI have no
|
||||||
|
// such file and build unsigned; that unsigned output is exactly what F-Droid's
|
||||||
|
// reproducible-build check compares against before re-applying our signature.
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties =
|
||||||
|
Properties().apply {
|
||||||
|
if (keystorePropertiesFile.exists()) keystorePropertiesFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "ca.ksamad.encore"
|
namespace = "ca.ksamad.encore"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -21,8 +33,24 @@ android {
|
|||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// Present only when the developer keystore is configured; absent on F-Droid/CI.
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
|
// Sign with the release key when configured; otherwise stay unsigned so
|
||||||
|
// F-Droid can build/verify reproducibly and re-apply our signature.
|
||||||
|
signingConfig = signingConfigs.findByName("release")
|
||||||
|
|
||||||
// R8 code shrinking + resource shrinking. Critical with
|
// R8 code shrinking + resource shrinking. Critical with
|
||||||
// material-icons-extended: it bundles thousands of vector icons, and
|
// material-icons-extended: it bundles thousands of vector icons, and
|
||||||
// R8 strips everything we don't reference (debug builds can't shrink,
|
// R8 strips everything we don't reference (debug builds can't shrink,
|
||||||
@@ -109,6 +137,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media)
|
implementation(libs.androidx.media)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.androidx.palette)
|
||||||
|
|
||||||
// The Compose BOM aligns every Compose artifact to one tested version set,
|
// The Compose BOM aligns every Compose artifact to one tested version set,
|
||||||
// so the individual Compose deps below are declared without versions.
|
// so the individual Compose deps below are declared without versions.
|
||||||
|
|||||||
@@ -12,7 +12,11 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".EncoreApplication"
|
android:name=".EncoreApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Encore">
|
android:theme="@style/Theme.Encore">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -4,40 +4,32 @@ import android.Manifest
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import ca.ksamad.encore.playback.VolumeKeyDispatcher
|
|
||||||
import ca.ksamad.encore.ui.EncoreApp
|
import ca.ksamad.encore.ui.EncoreApp
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val requestNotificationPermission =
|
private val requestNotificationPermission =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
|
||||||
|
|
||||||
// Intercept the hardware volume keys while we're focused so they drive the
|
|
||||||
// server volume without the system slider appearing (see VolumeKeyDispatcher).
|
|
||||||
private val volumeKeys by lazy {
|
|
||||||
VolumeKeyDispatcher((application as EncoreApplication).manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
maybeRequestNotificationPermission()
|
maybeRequestNotificationPermission()
|
||||||
setContent {
|
setContent {
|
||||||
EncoreTheme {
|
EncoreTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
// No inset padding here: each screen's own Scaffold/TopAppBar (or, for the
|
||||||
|
// plain-Column screens, their own systemBarsPadding) applies the system-bar
|
||||||
|
// insets. Doing it at the root too would double the status-bar gap.
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
EncoreApp()
|
EncoreApp()
|
||||||
@@ -45,10 +37,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean =
|
|
||||||
volumeKeys.dispatch(event) || super.dispatchKeyEvent(event)
|
|
||||||
|
|
||||||
// The media notification needs POST_NOTIFICATIONS on Android 13+. Without it
|
// The media notification needs POST_NOTIFICATIONS on Android 13+. Without it
|
||||||
// the foreground service still runs and cast volume still works, but the
|
// the foreground service still runs and cast volume still works, but the
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package ca.ksamad.encore.data
|
||||||
|
|
||||||
|
/** How the albums library is laid out: a compact [List] or a two-column [Grid] of cover art. */
|
||||||
|
enum class AlbumViewMode {
|
||||||
|
List,
|
||||||
|
Grid,
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Default when nothing is persisted yet. */
|
||||||
|
val DEFAULT = List
|
||||||
|
|
||||||
|
/** Parse a persisted name back to a mode, tolerating unknown/legacy values. */
|
||||||
|
fun fromName(name: String?): AlbumViewMode = entries.find { it.name == name } ?: DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ package ca.ksamad.encore.data
|
|||||||
data class ConnectionSettings(
|
data class ConnectionSettings(
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
|
/** Server password, or null when the server needs none. */
|
||||||
|
val password: String? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
/** First-run defaults, used until the user connects at least once. */
|
/** First-run defaults, used until the user connects at least once. */
|
||||||
|
|||||||
@@ -24,6 +24,48 @@ class SettingsRepository(private val context: Context) {
|
|||||||
private object Keys {
|
private object Keys {
|
||||||
val HOST = stringPreferencesKey("host")
|
val HOST = stringPreferencesKey("host")
|
||||||
val PORT = intPreferencesKey("port")
|
val PORT = intPreferencesKey("port")
|
||||||
|
val PASSWORD = stringPreferencesKey("password")
|
||||||
|
val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode")
|
||||||
|
val VOLUME_STEP = intPreferencesKey("volume_step")
|
||||||
|
val GRID_COLUMNS = intPreferencesKey("grid_columns")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the albums library is laid out; emits on every change, defaulted when unset. */
|
||||||
|
val albumViewMode: Flow<AlbumViewMode> =
|
||||||
|
context.dataStore.data.map { prefs -> AlbumViewMode.fromName(prefs[Keys.ALBUM_VIEW_MODE]) }
|
||||||
|
|
||||||
|
suspend fun setAlbumViewMode(mode: AlbumViewMode) {
|
||||||
|
context.dataStore.edit { prefs -> prefs[Keys.ALBUM_VIEW_MODE] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many percent each hardware volume-key press moves the server volume; emits on every
|
||||||
|
* change, clamped into range and defaulted when unset.
|
||||||
|
*/
|
||||||
|
val volumeStep: Flow<Int> =
|
||||||
|
context.dataStore.data.map { prefs ->
|
||||||
|
(prefs[Keys.VOLUME_STEP] ?: DEFAULT_VOLUME_STEP).coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setVolumeStep(step: Int) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.VOLUME_STEP] = step.coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many columns the albums grid layout uses; emits on every change, clamped into range and
|
||||||
|
* defaulted when unset.
|
||||||
|
*/
|
||||||
|
val gridColumns: Flow<Int> =
|
||||||
|
context.dataStore.data.map { prefs ->
|
||||||
|
(prefs[Keys.GRID_COLUMNS] ?: DEFAULT_GRID_COLUMNS).coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setGridColumns(columns: Int) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[Keys.GRID_COLUMNS] = columns.coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +78,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
ConnectionSettings(
|
ConnectionSettings(
|
||||||
host = host,
|
host = host,
|
||||||
port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port,
|
port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port,
|
||||||
|
password = prefs[Keys.PASSWORD],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +91,10 @@ class SettingsRepository(private val context: Context) {
|
|||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[Keys.HOST] = settings.host
|
prefs[Keys.HOST] = settings.host
|
||||||
prefs[Keys.PORT] = settings.port
|
prefs[Keys.PORT] = settings.port
|
||||||
|
// Drop the key entirely for a password-less server so we never persist a
|
||||||
|
// stray empty string (and a later read comes back as null, not "").
|
||||||
|
val pw = settings.password
|
||||||
|
if (pw.isNullOrBlank()) prefs.remove(Keys.PASSWORD) else prefs[Keys.PASSWORD] = pw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,4 +102,16 @@ class SettingsRepository(private val context: Context) {
|
|||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
context.dataStore.edit { it.clear() }
|
context.dataStore.edit { it.clear() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Volume-step bounds and default (percent per hardware volume-key press). */
|
||||||
|
const val DEFAULT_VOLUME_STEP = 1
|
||||||
|
const val MIN_VOLUME_STEP = 1
|
||||||
|
const val MAX_VOLUME_STEP = 10
|
||||||
|
|
||||||
|
/** Albums-grid column-count bounds and default. */
|
||||||
|
const val DEFAULT_GRID_COLUMNS = 2
|
||||||
|
const val MIN_GRID_COLUMNS = 1
|
||||||
|
const val MAX_GRID_COLUMNS = 4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,12 +129,15 @@ class MpdClient(
|
|||||||
/** Tear everything down; returns to [MpdConnectionState.Disconnected]. */
|
/** Tear everything down; returns to [MpdConnectionState.Disconnected]. */
|
||||||
suspend fun disconnect() {
|
suspend fun disconnect() {
|
||||||
shuttingDown = true
|
shuttingDown = true
|
||||||
stopBackgroundJobs()
|
// Close the sockets *before* joining the loops. The idle loop is parked in a blocking
|
||||||
// Closing the idle socket unblocks the loop's parked `idle` read.
|
// socket read that coroutine cancellation cannot interrupt, so closing the socket is what
|
||||||
|
// unblocks it. If we joined first (as this used to), disconnect() would hang until the
|
||||||
|
// server happened to push an idle event — which on a quiet server can be a very long time.
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
idleConn?.close()
|
idleConn?.close()
|
||||||
commandConn?.close()
|
commandConn?.close()
|
||||||
}
|
}
|
||||||
|
stopBackgroundJobs()
|
||||||
closeArtConnections()
|
closeArtConnections()
|
||||||
idleConn = null
|
idleConn = null
|
||||||
commandConn = null
|
commandConn = null
|
||||||
@@ -231,6 +234,27 @@ class MpdClient(
|
|||||||
MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
|
MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trigger a database scan for new/changed/removed files (whole library). */
|
||||||
|
suspend fun updateDatabase() = run(MpdCommands.update())
|
||||||
|
|
||||||
|
/** Trigger a full re-read of the database, including files with an unchanged mtime. */
|
||||||
|
suspend fun rescanDatabase() = run(MpdCommands.rescan())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the queue with [uris] (in the given order) and start playing the first. Used when the
|
||||||
|
* user taps a track to play an album from that track onward. No-op on an empty list.
|
||||||
|
*/
|
||||||
|
suspend fun playTracks(uris: List<String>) {
|
||||||
|
if (uris.isEmpty()) return
|
||||||
|
withCommand { conn ->
|
||||||
|
conn.executeList(
|
||||||
|
MpdCommands.clear(),
|
||||||
|
*uris.map { MpdCommands.add(it) }.toTypedArray(),
|
||||||
|
MpdCommands.play(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Replace the queue with an album and start playing it. */
|
/** Replace the queue with an album and start playing it. */
|
||||||
suspend fun playAlbum(
|
suspend fun playAlbum(
|
||||||
album: String,
|
album: String,
|
||||||
@@ -358,8 +382,13 @@ class MpdClient(
|
|||||||
// Cover fetches run on their own small pool of connections rather than the
|
// Cover fetches run on their own small pool of connections rather than the
|
||||||
// command connection, so (a) many covers load in parallel while scrolling and
|
// command connection, so (a) many covers load in parallel while scrolling and
|
||||||
// (b) art never blocks play/pause/volume. Connections are opened lazily, up
|
// (b) art never blocks play/pause/volume. Connections are opened lazily, up
|
||||||
// to ART_POOL_SIZE (bounded by the semaphore), reused when idle, and dropped
|
// to ART_POOL_SIZE (bounded by the semaphore), and reused when idle.
|
||||||
// on any transport error (a stale/timed-out one just gets reopened).
|
//
|
||||||
|
// Unlike the command connection, these carry no keepalive, so MPD reaps them
|
||||||
|
// after its connection_timeout (idle art is fetched rarely). A borrowed pooled
|
||||||
|
// connection may therefore be dead; reusing it throws a transport error. When
|
||||||
|
// that happens we discard it and retry once on a freshly opened connection, so
|
||||||
|
// a stale socket can't blank a cover until the app is restarted.
|
||||||
|
|
||||||
private val artSemaphore = Semaphore(ART_POOL_SIZE)
|
private val artSemaphore = Semaphore(ART_POOL_SIZE)
|
||||||
private val artPoolLock = Any()
|
private val artPoolLock = Any()
|
||||||
@@ -367,7 +396,29 @@ class MpdClient(
|
|||||||
|
|
||||||
private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
|
private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
|
||||||
artSemaphore.withPermit {
|
artSemaphore.withPermit {
|
||||||
val conn = borrowArtConnection()
|
// First try a pooled connection, if any. A transport failure here means
|
||||||
|
// it went stale — discard it (runArt already closed it) and fall through
|
||||||
|
// to a fresh one. A server ACK is a real result and is not retried.
|
||||||
|
borrowPooledArtConnection()?.let { pooled ->
|
||||||
|
try {
|
||||||
|
return@withPermit runArt(pooled, block)
|
||||||
|
} catch (e: MpdAckException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// stale pooled connection; retry on a fresh one below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runArt(openArtConnection(), block)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run [block] on [conn], returning it to the pool on success (or a clean `ACK`, which leaves the
|
||||||
|
* connection healthy) and closing it on any transport failure.
|
||||||
|
*/
|
||||||
|
private suspend fun <T> runArt(
|
||||||
|
conn: MpdConnection,
|
||||||
|
block: (MpdConnection) -> T,
|
||||||
|
): T =
|
||||||
try {
|
try {
|
||||||
val result = withContext(ioDispatcher) { block(conn) }
|
val result = withContext(ioDispatcher) { block(conn) }
|
||||||
returnArtConnection(conn)
|
returnArtConnection(conn)
|
||||||
@@ -379,21 +430,20 @@ class MpdClient(
|
|||||||
runCatching { conn.close() } // discard a broken connection
|
runCatching { conn.close() } // discard a broken connection
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun borrowArtConnection(): MpdConnection {
|
/** Pop an idle pooled art connection, or null if the pool is empty. */
|
||||||
|
private fun borrowPooledArtConnection(): MpdConnection? =
|
||||||
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }
|
synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }
|
||||||
?.let {
|
|
||||||
return it
|
/** Open, authenticate, and prime a brand-new art connection. */
|
||||||
}
|
private suspend fun openArtConnection(): MpdConnection =
|
||||||
return withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
val h = host ?: throw MpdConnectionException("not connected")
|
val h = host ?: throw MpdConnectionException("not connected")
|
||||||
val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
|
val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
|
||||||
password?.let { conn.execute(MpdCommands.password(it)) }
|
password?.let { conn.execute(MpdCommands.password(it)) }
|
||||||
runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
|
runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
|
||||||
conn
|
conn
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun returnArtConnection(conn: MpdConnection) {
|
private fun returnArtConnection(conn: MpdConnection) {
|
||||||
val kept =
|
val kept =
|
||||||
@@ -477,8 +527,11 @@ class MpdClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reconnectLoop() {
|
private suspend fun reconnectLoop() {
|
||||||
stopBackgroundJobs()
|
// Close first, then join — same reason as disconnect(): closing the sockets unblocks the
|
||||||
|
// idle loop's parked blocking read so stopBackgroundJobs() doesn't wait on it. (The idle
|
||||||
|
// loop's own error handler no-ops here because a reconnect is already in flight.)
|
||||||
closeConnectionsQuietly()
|
closeConnectionsQuietly()
|
||||||
|
stopBackgroundJobs()
|
||||||
_connectionState.value = MpdConnectionState.Connecting
|
_connectionState.value = MpdConnectionState.Connecting
|
||||||
var attempt = 0
|
var attempt = 0
|
||||||
while (scope.isActive && !shuttingDown) {
|
while (scope.isActive && !shuttingDown) {
|
||||||
@@ -533,8 +586,13 @@ class MpdClient(
|
|||||||
private const val RECONNECT_BACKOFF_MS = 1_000L
|
private const val RECONNECT_BACKOFF_MS = 1_000L
|
||||||
private const val MAX_RECONNECT_ATTEMPTS = 5
|
private const val MAX_RECONNECT_ATTEMPTS = 5
|
||||||
|
|
||||||
/** Idle subsystems that change something [status]/[currentSong] reflects. */
|
/**
|
||||||
private val REFRESHING_SUBSYSTEMS = setOf("player", "mixer", "options", "playlist")
|
* Idle subsystems that change something [status]/[currentSong] reflects. `update` fires when
|
||||||
|
* a database scan starts and finishes (toggling `status.updating`); `database` fires when the
|
||||||
|
* scan actually changed the library.
|
||||||
|
*/
|
||||||
|
private val REFRESHING_SUBSYSTEMS =
|
||||||
|
setOf("player", "mixer", "options", "playlist", "update", "database")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,21 @@ object MpdCommands {
|
|||||||
|
|
||||||
// --- Database / library -------------------------------------------------
|
// --- Database / library -------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the music database for new, modified, and removed files (whole library when [path] is
|
||||||
|
* null). Respects file modification times, so unchanged files are skipped. Returns immediately
|
||||||
|
* with `updating_db: <jobid>`; the scan runs in the background.
|
||||||
|
*/
|
||||||
|
fun update(path: String? = null) =
|
||||||
|
if (path == null) MpdProtocol.command("update") else MpdProtocol.command("update", path)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [update], but also re-reads files whose modification time is unchanged — the "hard"
|
||||||
|
* refresh, for when tags changed without touching mtime. Heavier on the server.
|
||||||
|
*/
|
||||||
|
fun rescan(path: String? = null) =
|
||||||
|
if (path == null) MpdProtocol.command("rescan") else MpdProtocol.command("rescan", path)
|
||||||
|
|
||||||
/** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */
|
/** Every album, grouped by album artist (`Album`/`AlbumArtist` lines). */
|
||||||
fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist")
|
fun listAlbums() = MpdProtocol.command("list", "album", "group", "albumartist")
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ data class MpdSong(
|
|||||||
val disc: String?,
|
val disc: String?,
|
||||||
val date: String?,
|
val date: String?,
|
||||||
val genre: String?,
|
val genre: String?,
|
||||||
|
val lastModified: String?, // "Last-Modified" ISO timestamp; used to cache-bust cover art
|
||||||
|
val format: String?, // "Format" audio format "samplerate:bits:channels" (from the DB scan)
|
||||||
val duration: Double?, // from "duration" (fractional) or legacy "Time"
|
val duration: Double?, // from "duration" (fractional) or legacy "Time"
|
||||||
val pos: Int?, // queue position, present in queue listings
|
val pos: Int?, // queue position, present in queue listings
|
||||||
val id: Int?, // stable queue id, present in queue listings
|
val id: Int?, // stable queue id, present in queue listings
|
||||||
@@ -39,6 +41,8 @@ data class MpdSong(
|
|||||||
disc = values["Disc"],
|
disc = values["Disc"],
|
||||||
date = values["Date"],
|
date = values["Date"],
|
||||||
genre = values["Genre"],
|
genre = values["Genre"],
|
||||||
|
lastModified = values["Last-Modified"],
|
||||||
|
format = values["Format"],
|
||||||
duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(),
|
duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(),
|
||||||
pos = values["Pos"]?.toIntOrNull(),
|
pos = values["Pos"]?.toIntOrNull(),
|
||||||
id = values["Id"]?.toIntOrNull(),
|
id = values["Id"]?.toIntOrNull(),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ data class MpdStatus(
|
|||||||
val bitrate: Int?, // instantaneous kbps
|
val bitrate: Int?, // instantaneous kbps
|
||||||
val audio: String?, // e.g. "44100:16:2"
|
val audio: String?, // e.g. "44100:16:2"
|
||||||
val error: String?, // last player error, if any
|
val error: String?, // last player error, if any
|
||||||
|
val updating: Boolean = false, // true while the server is scanning its database (`updating_db`)
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(values: Map<String, String>): MpdStatus =
|
fun from(values: Map<String, String>): MpdStatus =
|
||||||
@@ -64,6 +65,8 @@ data class MpdStatus(
|
|||||||
bitrate = values["bitrate"]?.toIntOrNull(),
|
bitrate = values["bitrate"]?.toIntOrNull(),
|
||||||
audio = values["audio"],
|
audio = values["audio"],
|
||||||
error = values["error"],
|
error = values["error"],
|
||||||
|
// Present (a job id) only while a database update/rescan is running.
|
||||||
|
updating = values["updating_db"] != null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,16 @@ object ArtImageLoader {
|
|||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Stable cache key per art request; shared by the keyer and the disk cache. */
|
/**
|
||||||
|
* Stable cache key per art request; shared by the keyer and the disk cache. For [SongArt] the
|
||||||
|
* optional [SongArt.version] is appended so a re-tagged file (new mtime) lands on a fresh key
|
||||||
|
* and refetches; album keys stay version-free so an album's tracks share one entry.
|
||||||
|
*/
|
||||||
private fun cacheKey(data: MpdArtData): String =
|
private fun cacheKey(data: MpdArtData): String =
|
||||||
when (data) {
|
when (data) {
|
||||||
is SongArt -> "song:${data.uri}"
|
is SongArt ->
|
||||||
|
if (data.version.isNullOrEmpty()) "song:${data.uri}"
|
||||||
|
else "song:${data.uri}@${data.version}"
|
||||||
is AlbumArt -> "album:${data.albumArtist.orEmpty()}/${data.album}"
|
is AlbumArt -> "album:${data.albumArtist.orEmpty()}/${data.album}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,24 @@ package ca.ksamad.encore.playback
|
|||||||
*/
|
*/
|
||||||
sealed interface MpdArtData
|
sealed interface MpdArtData
|
||||||
|
|
||||||
/** Cover art for a specific song URI (used for now-playing and the notification). */
|
/**
|
||||||
data class SongArt(val uri: String) : MpdArtData
|
* Cover art for a specific song URI (used for now-playing and the notification).
|
||||||
|
*
|
||||||
|
* [version] is an opaque cache-bust token — typically the file's `Last-Modified` timestamp. It has
|
||||||
|
* no effect on how the art is fetched (only [uri] matters for that); it only participates in the
|
||||||
|
* cache key, so when a track is re-tagged/re-imported (its mtime changes) the new art is fetched
|
||||||
|
* automatically instead of serving a stale cached copy.
|
||||||
|
*/
|
||||||
|
data class SongArt(
|
||||||
|
val uri: String,
|
||||||
|
val version: String? = null,
|
||||||
|
) : MpdArtData
|
||||||
|
|
||||||
/** Cover art for an album (a representative track is resolved server-side). */
|
/**
|
||||||
|
* Cover art for an album (a representative track is resolved server-side). Deliberately has no
|
||||||
|
* per-track version: every track of an album must resolve to one cache entry (see the queue), so
|
||||||
|
* album art is refreshed via the manual "Refresh artwork" action rather than mtime.
|
||||||
|
*/
|
||||||
data class AlbumArt(
|
data class AlbumArt(
|
||||||
val album: String,
|
val album: String,
|
||||||
val albumArtist: String?,
|
val albumArtist: String?,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ca.ksamad.encore.playback
|
package ca.ksamad.encore.playback
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import ca.ksamad.encore.data.AlbumViewMode
|
||||||
import ca.ksamad.encore.data.ConnectionSettings
|
import ca.ksamad.encore.data.ConnectionSettings
|
||||||
import ca.ksamad.encore.data.SettingsRepository
|
import ca.ksamad.encore.data.SettingsRepository
|
||||||
import ca.ksamad.encore.mpd.MpdClient
|
import ca.ksamad.encore.mpd.MpdClient
|
||||||
@@ -8,6 +9,7 @@ import ca.ksamad.encore.mpd.MpdConnectionState
|
|||||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
import ca.ksamad.encore.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -47,6 +49,38 @@ class MpdConnectionManager(context: Context) {
|
|||||||
val settings: StateFlow<ConnectionSettings> =
|
val settings: StateFlow<ConnectionSettings> =
|
||||||
settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)
|
settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)
|
||||||
|
|
||||||
|
/** Persisted albums-library layout preference. */
|
||||||
|
val albumViewMode: StateFlow<AlbumViewMode> =
|
||||||
|
settingsRepo.albumViewMode.stateIn(scope, SharingStarted.Eagerly, AlbumViewMode.DEFAULT)
|
||||||
|
|
||||||
|
fun setAlbumViewMode(mode: AlbumViewMode) {
|
||||||
|
scope.launch { settingsRepo.setAlbumViewMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisted volume-key step (percent per press). */
|
||||||
|
val volumeStep: StateFlow<Int> =
|
||||||
|
settingsRepo.volumeStep.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
SettingsRepository.DEFAULT_VOLUME_STEP,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setVolumeStep(step: Int) {
|
||||||
|
scope.launch { settingsRepo.setVolumeStep(step) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisted albums-grid column count. */
|
||||||
|
val gridColumns: StateFlow<Int> =
|
||||||
|
settingsRepo.gridColumns.stateIn(
|
||||||
|
scope,
|
||||||
|
SharingStarted.Eagerly,
|
||||||
|
SettingsRepository.DEFAULT_GRID_COLUMNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setGridColumns(columns: Int) {
|
||||||
|
scope.launch { settingsRepo.setGridColumns(columns) }
|
||||||
|
}
|
||||||
|
|
||||||
// True until the startup auto-connect decision has been made.
|
// True until the startup auto-connect decision has been made.
|
||||||
private val _bootstrapping = MutableStateFlow(true)
|
private val _bootstrapping = MutableStateFlow(true)
|
||||||
val bootstrapping: StateFlow<Boolean> = _bootstrapping
|
val bootstrapping: StateFlow<Boolean> = _bootstrapping
|
||||||
@@ -72,7 +106,7 @@ class MpdConnectionManager(context: Context) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val saved = settingsRepo.settingsOrNull.first()
|
val saved = settingsRepo.settingsOrNull.first()
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
connect(saved.host, saved.port)
|
connect(saved.host, saved.port, saved.password)
|
||||||
connectionState.first { it !is MpdConnectionState.Disconnected }
|
connectionState.first { it !is MpdConnectionState.Disconnected }
|
||||||
}
|
}
|
||||||
_bootstrapping.value = false
|
_bootstrapping.value = false
|
||||||
@@ -100,12 +134,15 @@ class MpdConnectionManager(context: Context) {
|
|||||||
fun connect(
|
fun connect(
|
||||||
host: String,
|
host: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
|
password: String? = null,
|
||||||
) {
|
) {
|
||||||
val trimmed = host.trim()
|
val trimmed = host.trim()
|
||||||
|
// Treat a blank password as "no password" everywhere downstream.
|
||||||
|
val pw = password?.trim()?.ifBlank { null }
|
||||||
_serverHost.value = trimmed
|
_serverHost.value = trimmed
|
||||||
scope.launch {
|
scope.launch {
|
||||||
settingsRepo.save(ConnectionSettings(trimmed, port))
|
settingsRepo.save(ConnectionSettings(trimmed, port, pw))
|
||||||
runCatching { client.connect(trimmed, port) }
|
runCatching { client.connect(trimmed, port, pw) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +201,9 @@ class MpdConnectionManager(context: Context) {
|
|||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
) = fire { playAlbumNext(album, albumArtist) }
|
) = fire { playAlbumNext(album, albumArtist) }
|
||||||
|
|
||||||
|
/** Replace the queue with [uris] (in order) and start playing the first. */
|
||||||
|
fun playTracks(uris: List<String>) = fire { playTracks(uris) }
|
||||||
|
|
||||||
/** Append a single track to the end of the queue. */
|
/** Append a single track to the end of the queue. */
|
||||||
fun queueTrack(uri: String) = fire { queueTrack(uri) }
|
fun queueTrack(uri: String) = fire { queueTrack(uri) }
|
||||||
|
|
||||||
@@ -196,10 +236,29 @@ class MpdConnectionManager(context: Context) {
|
|||||||
suspend fun loadAlbums(): List<MpdAlbum> =
|
suspend fun loadAlbums(): List<MpdAlbum> =
|
||||||
runCatching { client.albums() }.getOrDefault(emptyList())
|
runCatching { client.albums() }.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/** Ask the server to scan for new/changed/removed files. */
|
||||||
|
fun updateDatabase() = fire { updateDatabase() }
|
||||||
|
|
||||||
|
/** Ask the server to fully re-read the database, including unchanged files. */
|
||||||
|
fun rescanDatabase() = fire { rescanDatabase() }
|
||||||
|
|
||||||
/** One-shot play-queue fetch; returns empty on any failure. */
|
/** One-shot play-queue fetch; returns empty on any failure. */
|
||||||
suspend fun loadQueue(): List<MpdSong> =
|
suspend fun loadQueue(): List<MpdSong> =
|
||||||
runCatching { client.queue() }.getOrDefault(emptyList())
|
runCatching { client.queue() }.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every cached cover (memory + disk) so the next load refetches from the server. Backs the
|
||||||
|
* "Refresh artwork" action — for art that isn't mtime-keyed (album covers, and folder cover
|
||||||
|
* files MPD serves without touching the track's mtime), this is the way to pick up changes made
|
||||||
|
* on the server. Album-keyed art then reloads as each screen is reopened.
|
||||||
|
*/
|
||||||
|
fun refreshArtwork() {
|
||||||
|
val loader = SingletonImageLoader.get(appContext)
|
||||||
|
loader.memoryCache?.clear()
|
||||||
|
// Disk clear touches the filesystem — keep it off the main thread.
|
||||||
|
scope.launch(Dispatchers.IO) { loader.diskCache?.clear() }
|
||||||
|
}
|
||||||
|
|
||||||
/** Fetch cover-art bytes for Coil (null on miss/failure). */
|
/** Fetch cover-art bytes for Coil (null on miss/failure). */
|
||||||
suspend fun fetchArt(data: MpdArtData): ByteArray? =
|
suspend fun fetchArt(data: MpdArtData): ByteArray? =
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -210,19 +269,11 @@ class MpdConnectionManager(context: Context) {
|
|||||||
}
|
}
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|
||||||
/**
|
/** Relative volume change (from the media session's remote VolumeProvider), accumulating. */
|
||||||
* True when we own the volume: connected to a server that exposes a mixer. Used to decide
|
|
||||||
* whether hardware volume keys drive the *server* (silently, in-app) or fall through to the
|
|
||||||
* device's own local volume.
|
|
||||||
*/
|
|
||||||
val isControllingVolume: Boolean
|
|
||||||
get() =
|
|
||||||
connectionState.value is MpdConnectionState.Connected && status.value?.volume != null
|
|
||||||
|
|
||||||
/** Relative volume change (from a hardware key / VolumeProvider), accumulating. */
|
|
||||||
fun nudgeVolume(up: Boolean) {
|
fun nudgeVolume(up: Boolean) {
|
||||||
val base = pendingVolume ?: status.value?.volume ?: return
|
val base = pendingVolume ?: status.value?.volume ?: return
|
||||||
val next = (base + if (up) VOLUME_STEP else -VOLUME_STEP).coerceIn(0, 100)
|
val step = volumeStep.value
|
||||||
|
val next = (base + if (up) step else -step).coerceIn(0, 100)
|
||||||
pendingVolume = next
|
pendingVolume = next
|
||||||
setVolume(next)
|
setVolume(next)
|
||||||
}
|
}
|
||||||
@@ -232,7 +283,6 @@ class MpdConnectionManager(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val VOLUME_STEP = 5
|
|
||||||
const val VOLUME_THROTTLE_MS = 120L
|
const val VOLUME_THROTTLE_MS = 120L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ class PlaybackService : Service() {
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collectLatest { uri ->
|
.collectLatest { uri ->
|
||||||
artUri = uri
|
artUri = uri
|
||||||
artBitmap = if (uri != null) loadArtBitmap(uri) else null
|
val version = manager.currentSong.value?.takeIf { it.uri == uri }?.lastModified
|
||||||
|
artBitmap = if (uri != null) loadArtBitmap(uri, version) else null
|
||||||
if (artUri == uri) {
|
if (artUri == uri) {
|
||||||
session.setMetadata(
|
session.setMetadata(
|
||||||
buildMetadata(manager.currentSong.value, manager.status.value)
|
buildMetadata(manager.currentSong.value, manager.status.value)
|
||||||
@@ -164,10 +165,15 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadArtBitmap(uri: String): Bitmap? {
|
private suspend fun loadArtBitmap(
|
||||||
|
uri: String,
|
||||||
|
version: String?,
|
||||||
|
): Bitmap? {
|
||||||
val result =
|
val result =
|
||||||
SingletonImageLoader.get(applicationContext)
|
SingletonImageLoader.get(applicationContext)
|
||||||
.execute(ImageRequest.Builder(applicationContext).data(SongArt(uri)).build())
|
.execute(
|
||||||
|
ImageRequest.Builder(applicationContext).data(SongArt(uri, version)).build()
|
||||||
|
)
|
||||||
return (result as? SuccessResult)?.image?.toBitmap()
|
return (result as? SuccessResult)?.image?.toBitmap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package ca.ksamad.encore.playback
|
|
||||||
|
|
||||||
import android.view.KeyEvent
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encapsulates hardware volume-key handling for the foreground Activity.
|
|
||||||
*
|
|
||||||
* While the app is focused, we want the volume keys to drive the *server* volume **silently** —
|
|
||||||
* without the system's volume slider popping up (the app already shows its own). The trick is to
|
|
||||||
* fully consume the key event in the Activity so it never reaches the OS volume handling that draws
|
|
||||||
* that slider.
|
|
||||||
*
|
|
||||||
* When the app is backgrounded the Activity isn't in the dispatch path at all, so the
|
|
||||||
* `MediaSession`'s `VolumeProvider` handles the keys instead — there the OS remote-volume UI
|
|
||||||
* showing up is the expected, cast-style behaviour.
|
|
||||||
*
|
|
||||||
* Only volume keys we actually act on are consumed: when we're not controlling the server (e.g. the
|
|
||||||
* connect screen), the event passes through so the device adjusts its own local volume normally.
|
|
||||||
*/
|
|
||||||
class VolumeKeyDispatcher(private val manager: MpdConnectionManager) {
|
|
||||||
/**
|
|
||||||
* Offer [event] to the volume handler. Returns `true` if it was a volume key we handled and
|
|
||||||
* consumed (caller should then *not* pass it on), `false` to let normal dispatch continue.
|
|
||||||
*/
|
|
||||||
fun dispatch(event: KeyEvent): Boolean {
|
|
||||||
val up =
|
|
||||||
when (event.keyCode) {
|
|
||||||
KeyEvent.KEYCODE_VOLUME_UP -> true
|
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN -> false
|
|
||||||
else -> return false
|
|
||||||
}
|
|
||||||
if (!manager.isControllingVolume) return false
|
|
||||||
|
|
||||||
// Nudge on each key-down (auto-repeats included, so holding keeps
|
|
||||||
// changing); swallow the key-up too so no system UI flashes.
|
|
||||||
if (event.action == KeyEvent.ACTION_DOWN) manager.nudgeVolume(up)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.palette.graphics.Palette
|
||||||
|
import ca.ksamad.encore.playback.SongArt
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.request.allowHardware
|
||||||
|
import coil3.size.Size
|
||||||
|
import coil3.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// Small in-memory cache of extracted accent colours (ARGB ints), keyed by the art's cache key, so
|
||||||
|
// revisiting a song — or a recomposition — never re-runs Palette.
|
||||||
|
private val accentCache = LruCache<String, Int>(64)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dominant accent colour of [art]'s cover, used for the now-playing background bleed.
|
||||||
|
*
|
||||||
|
* Pulls a small bitmap from Coil (served from its memory/disk cache, so no extra network request),
|
||||||
|
* runs [Palette] off the main thread, and memoises the result. Returns [fallback] until it resolves,
|
||||||
|
* and whenever there's no art or extraction fails — so callers can just use the value directly.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberArtAccentColor(
|
||||||
|
art: SongArt?,
|
||||||
|
fallback: Color,
|
||||||
|
): Color {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return produceState(initialValue = fallback, art) {
|
||||||
|
if (art == null) {
|
||||||
|
value = fallback
|
||||||
|
return@produceState
|
||||||
|
}
|
||||||
|
val key = "${art.uri}@${art.version.orEmpty()}"
|
||||||
|
accentCache.get(key)?.let {
|
||||||
|
value = Color(it)
|
||||||
|
return@produceState
|
||||||
|
}
|
||||||
|
val argb =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
runCatching {
|
||||||
|
val loader = SingletonImageLoader.get(context)
|
||||||
|
val result =
|
||||||
|
loader.execute(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(art)
|
||||||
|
.size(Size(128, 128)) // tiny: Palette downsamples anyway
|
||||||
|
.allowHardware(false) // Palette must read pixels on the CPU
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val bitmap =
|
||||||
|
(result as? SuccessResult)?.image?.toBitmap() ?: return@runCatching null
|
||||||
|
val palette = Palette.from(bitmap).maximumColorCount(16).generate()
|
||||||
|
val swatch =
|
||||||
|
palette.dominantSwatch ?: palette.vibrantSwatch ?: palette.mutedSwatch
|
||||||
|
swatch?.let { 0xFF000000.toInt() or it.rgb } // force opaque
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
if (argb != null) {
|
||||||
|
accentCache.put(key, argb)
|
||||||
|
value = Color(argb)
|
||||||
|
} else {
|
||||||
|
value = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.value
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -38,6 +40,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
@@ -47,9 +50,9 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One album, in detail: its cover, whole-album Play / Add-to-queue actions, and the track list.
|
* One album, in detail: its cover, whole-album Play / Add-to-queue actions, and the track list.
|
||||||
* Tapping Play replaces the queue and starts the album (leaving this screen); swiping a track
|
* Tapping Play replaces the queue and starts the album (leaving this screen); tapping a track does
|
||||||
* queues it (right) or plays it next (left), with the same semantics as swiping an album in the
|
* the same but starts the album from that track onward. Swiping a track queues it (right) or plays
|
||||||
* library.
|
* it next (left), with the same semantics as swiping an album in the library.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -65,6 +68,10 @@ fun AlbumDetailScreen(
|
|||||||
value = vm.loadAlbumTracks(album.name, album.albumArtist)
|
value = vm.loadAlbumTracks(album.name, album.albumArtist)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The album's audio format, taken from the tracks we already loaded (MPD reports a per-song
|
||||||
|
// `Format` in the track listing), so no extra network call. Null until tracks arrive.
|
||||||
|
val albumFormat = remember(tracks) { tracks?.let(::representativeFormat) }
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -90,6 +97,7 @@ fun AlbumDetailScreen(
|
|||||||
item {
|
item {
|
||||||
AlbumDetailHeader(
|
AlbumDetailHeader(
|
||||||
album = album,
|
album = album,
|
||||||
|
format = albumFormat,
|
||||||
onPlay = {
|
onPlay = {
|
||||||
vm.playAlbum(album.name, album.albumArtist)
|
vm.playAlbum(album.name, album.albumArtist)
|
||||||
onPlay()
|
onPlay()
|
||||||
@@ -101,11 +109,21 @@ fun AlbumDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One track row, wired to its swipe actions. Reused whether the list is
|
// One track row, wired to its tap + swipe actions. Reused whether the list
|
||||||
// flat or split into disc groups.
|
// is flat or split into disc groups.
|
||||||
val trackItem: @Composable (MpdSong) -> Unit = { track ->
|
val trackItem: @Composable (MpdSong) -> Unit = { track ->
|
||||||
TrackRow(
|
TrackRow(
|
||||||
track = track,
|
track = track,
|
||||||
|
onPlay = {
|
||||||
|
// Play the album from this track on: replace the queue with the
|
||||||
|
// tapped track and everything after it (the list is already in
|
||||||
|
// disc/track order), then start playing. Stay on the album page —
|
||||||
|
// just flash a confirmation rather than jumping to now-playing.
|
||||||
|
val loaded = tracks ?: return@TrackRow
|
||||||
|
val fromHere = loaded.dropWhile { it.uri != track.uri }.map { it.uri }
|
||||||
|
vm.playTracks(fromHere)
|
||||||
|
flash("Playing “${track.title ?: track.uri}”")
|
||||||
|
},
|
||||||
onQueue = {
|
onQueue = {
|
||||||
vm.queueTrack(track.uri)
|
vm.queueTrack(track.uri)
|
||||||
flash("Added “${track.title ?: track.uri}” to the queue")
|
flash("Added “${track.title ?: track.uri}” to the queue")
|
||||||
@@ -162,10 +180,11 @@ fun AlbumDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cover, title/artist, and the two whole-album action buttons. */
|
/** Cover, title/artist, audio-format pills, and the two whole-album action buttons. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlbumDetailHeader(
|
private fun AlbumDetailHeader(
|
||||||
album: MpdAlbum,
|
album: MpdAlbum,
|
||||||
|
format: String?,
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onQueue: () -> Unit,
|
onQueue: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -176,12 +195,13 @@ private fun AlbumDetailHeader(
|
|||||||
ArtImage(
|
ArtImage(
|
||||||
model = AlbumArt(album.name, album.albumArtist),
|
model = AlbumArt(album.name, album.albumArtist),
|
||||||
iconSize = 72.dp,
|
iconSize = 72.dp,
|
||||||
modifier = Modifier.size(220.dp).clip(RoundedCornerShape(12.dp)),
|
modifier = Modifier.fillMaxWidth(0.85f).aspectRatio(1f).clip(RoundedCornerShape(12.dp)),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(16.dp))
|
Spacer(Modifier.size(16.dp))
|
||||||
Text(
|
Text(
|
||||||
album.name,
|
album.name,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -190,10 +210,12 @@ private fun AlbumDetailHeader(
|
|||||||
it,
|
it,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AudioFormatPills(pills = audioFormatPills(format), modifier = Modifier.padding(top = 10.dp))
|
||||||
Spacer(Modifier.size(16.dp))
|
Spacer(Modifier.size(16.dp))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -212,18 +234,21 @@ private fun AlbumDetailHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single track row. Swiping right queues the track, swiping left plays it next — the same gesture
|
* A single track row. Tapping plays the album from this track onward (replacing the queue); swiping
|
||||||
* as the album library, scoped to this one track. The leading slot shows the track number (all
|
* right queues just this track and swiping left plays it next — the same gestures as the album
|
||||||
* tracks share the album's cover, so a per-track thumbnail would be redundant here).
|
* library, scoped to this one track. The leading slot shows the track number (all tracks share the
|
||||||
|
* album's cover, so a per-track thumbnail would be redundant here).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackRow(
|
private fun TrackRow(
|
||||||
track: MpdSong,
|
track: MpdSong,
|
||||||
|
onPlay: () -> Unit,
|
||||||
onQueue: () -> Unit,
|
onQueue: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
) {
|
) {
|
||||||
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
||||||
ListItem(
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(onClick = onPlay),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
@@ -263,3 +288,16 @@ private fun trackNumberOf(track: MpdSong): Int? =
|
|||||||
/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */
|
/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */
|
||||||
private fun discNumberOf(track: MpdSong): Int =
|
private fun discNumberOf(track: MpdSong): Int =
|
||||||
track.disc?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 1
|
track.disc?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The album's representative audio format: the most common per-track `Format`
|
||||||
|
* (`samplerate:bits:channels`) among the loaded tracks. Null when no track reports one. Using the
|
||||||
|
* mode keeps a stray transcoded/hidden track from misrepresenting the album.
|
||||||
|
*/
|
||||||
|
private fun representativeFormat(tracks: List<MpdSong>): String? =
|
||||||
|
tracks
|
||||||
|
.mapNotNull { it.format }
|
||||||
|
.groupingBy { it }
|
||||||
|
.eachCount()
|
||||||
|
.maxByOrNull { it.value }
|
||||||
|
?.key
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
@@ -44,9 +53,12 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ca.ksamad.encore.data.AlbumViewMode
|
||||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||||
import ca.ksamad.encore.playback.AlbumArt
|
import ca.ksamad.encore.playback.AlbumArt
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -59,9 +71,16 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
fun AlbumsScreen(
|
fun AlbumsScreen(
|
||||||
vm: PlayerViewModel,
|
vm: PlayerViewModel,
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
listState: LazyListState,
|
||||||
|
gridState: LazyGridState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenAlbum: (MpdAlbum) -> Unit,
|
onOpenAlbum: (MpdAlbum) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
||||||
|
val gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// null = still loading.
|
// null = still loading.
|
||||||
val albums by
|
val albums by
|
||||||
produceState<List<MpdAlbum>?>(initialValue = null) {
|
produceState<List<MpdAlbum>?>(initialValue = null) {
|
||||||
@@ -72,9 +91,10 @@ fun AlbumsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side search over the already-loaded index (album title + album artist).
|
// The search text is hoisted (owned by EncoreApp) so it survives drilling into
|
||||||
var searching by remember { mutableStateOf(false) }
|
// an album and coming back. Whether the search bar is *open* is local, but it
|
||||||
var query by remember { mutableStateOf("") }
|
// starts open whenever there's a restored query so returning re-shows the search.
|
||||||
|
var searching by remember { mutableStateOf(query.isNotEmpty()) }
|
||||||
|
|
||||||
// Transient confirmation for the swipe actions (add-to-queue / play-next).
|
// Transient confirmation for the swipe actions (add-to-queue / play-next).
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -116,7 +136,7 @@ fun AlbumsScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
if (searching) {
|
if (searching) {
|
||||||
AlbumSearchField(query = query, onQueryChange = { query = it })
|
AlbumSearchField(query = query, onQueryChange = onQueryChange)
|
||||||
} else {
|
} else {
|
||||||
Text(filtered?.let { "Albums (${it.size})" } ?: "Albums")
|
Text(filtered?.let { "Albums (${it.size})" } ?: "Albums")
|
||||||
}
|
}
|
||||||
@@ -126,7 +146,7 @@ fun AlbumsScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
if (searching) {
|
if (searching) {
|
||||||
searching = false
|
searching = false
|
||||||
query = ""
|
onQueryChange("")
|
||||||
} else {
|
} else {
|
||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
@@ -137,7 +157,7 @@ fun AlbumsScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (searching) {
|
if (searching) {
|
||||||
IconButton(onClick = { query = "" }, enabled = query.isNotEmpty()) {
|
IconButton(onClick = { onQueryChange("") }, enabled = query.isNotEmpty()) {
|
||||||
Icon(Icons.Filled.Clear, contentDescription = "Clear search")
|
Icon(Icons.Filled.Clear, contentDescription = "Clear search")
|
||||||
}
|
}
|
||||||
} else if (current != null) {
|
} else if (current != null) {
|
||||||
@@ -173,8 +193,23 @@ fun AlbumsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewMode == AlbumViewMode.Grid -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(gridColumns),
|
||||||
|
state = gridState,
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
contentPadding = PaddingValues(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
||||||
|
AlbumGridCell(album = album, onTap = { onOpenAlbum(album) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
LazyColumn(state = listState, modifier = Modifier.padding(innerPadding)) {
|
||||||
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album ->
|
||||||
SwipeableAlbumRow(
|
SwipeableAlbumRow(
|
||||||
album = album,
|
album = album,
|
||||||
@@ -229,8 +264,46 @@ private fun SwipeableAlbumRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard;
|
* One album tile for the grid layout: square cover art with the album name in bold underneath and
|
||||||
* the surrounding [TopAppBar] handles clearing/closing.
|
* the artist in a smaller, muted font below that. Tapping opens the album ([onTap]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AlbumGridCell(
|
||||||
|
album: MpdAlbum,
|
||||||
|
onTap: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.clickable(onClick = onTap)) {
|
||||||
|
ArtImage(
|
||||||
|
model = AlbumArt(album.name, album.albumArtist),
|
||||||
|
iconSize = 48.dp,
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
album.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
album.albumArtist?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard
|
||||||
|
* only on a fresh (empty) open — when it reappears already populated (e.g. restored after returning
|
||||||
|
* from an album) it shows the query and results without stealing focus. The surrounding [TopAppBar]
|
||||||
|
* handles clearing/closing.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -259,5 +332,6 @@ private fun AlbumSearchField(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
// Only grab focus for a fresh search; a restored (non-empty) query shouldn't pop the keyboard.
|
||||||
|
LaunchedEffect(Unit) { if (query.isEmpty()) focusRequester.requestFocus() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of little rounded pills describing an audio format — sample rate, bit depth, and channel
|
||||||
|
* layout (e.g. "44.1 kHz", "16-bit", "Stereo"). Shared by now-playing (the live stream) and album
|
||||||
|
* detail (an album's stored format). Renders nothing when [pills] is empty.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AudioFormatPills(
|
||||||
|
pills: List<String>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (pills.isEmpty()) return
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
pills.forEach { pill ->
|
||||||
|
Text(
|
||||||
|
text = pill,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier =
|
||||||
|
Modifier.clip(RoundedCornerShape(50))
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an MPD audio-format string, `samplerate:bits:channels` (e.g. `"44100:16:2"`), into pill
|
||||||
|
* labels. The middle token can be `f` (float) or `dsd` instead of a bit depth. Only well-formed
|
||||||
|
* parts appear; a null/garbage input yields an empty list.
|
||||||
|
*/
|
||||||
|
fun audioFormatPills(format: String?): List<String> {
|
||||||
|
val parts = format?.split(":")?.takeIf { it.size >= 3 } ?: return emptyList()
|
||||||
|
return buildList {
|
||||||
|
parts[0].toIntOrNull()?.let { add(formatSampleRate(it)) }
|
||||||
|
formatSampleFormat(parts[1])?.let { add(it) }
|
||||||
|
formatChannels(parts[2])?.let { add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSampleRate(hz: Int): String {
|
||||||
|
val khz = hz / 1000.0
|
||||||
|
val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz)
|
||||||
|
return "$value kHz"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatSampleFormat(token: String): String? =
|
||||||
|
when (token) {
|
||||||
|
"f" -> "Float"
|
||||||
|
"dsd" -> "DSD"
|
||||||
|
else -> token.toIntOrNull()?.let { "$it-bit" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatChannels(token: String): String? =
|
||||||
|
when (token.toIntOrNull()) {
|
||||||
|
null -> null
|
||||||
|
1 -> "Mono"
|
||||||
|
2 -> "Stereo"
|
||||||
|
else -> "$token ch"
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -7,8 +8,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -22,6 +30,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -55,6 +65,14 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
||||||
// When set (within the Albums overlay), the album detail screen is shown.
|
// When set (within the Albums overlay), the album detail screen is shown.
|
||||||
var detailAlbum by rememberSaveable(stateSaver = AlbumSaver) { mutableStateOf<MpdAlbum?>(null) }
|
var detailAlbum by rememberSaveable(stateSaver = AlbumSaver) { mutableStateOf<MpdAlbum?>(null) }
|
||||||
|
// Owned here (not in AlbumsScreen) so the search text survives drilling into an
|
||||||
|
// album and back — AlbumsScreen leaves composition while the detail is shown.
|
||||||
|
var albumSearch by rememberSaveable { mutableStateOf("") }
|
||||||
|
// Same reasoning for the library's scroll position (one per layout), so backing
|
||||||
|
// out of an album returns you to where you were. rememberLazy*State is itself
|
||||||
|
// saveable, so these also survive rotation/process death.
|
||||||
|
val albumListState = rememberLazyListState()
|
||||||
|
val albumGridState = rememberLazyGridState()
|
||||||
|
|
||||||
// Overlays only make sense over the player; leaving it (e.g. after a
|
// Overlays only make sense over the player; leaving it (e.g. after a
|
||||||
// reset/disconnect) drops us back to the normal screen flow.
|
// reset/disconnect) drops us back to the normal screen flow.
|
||||||
@@ -65,6 +83,13 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forget the album search once we leave the library entirely (to the player or
|
||||||
|
// another overlay). The detail screen keeps overlay == Albums, so browsing into
|
||||||
|
// an album and back preserves it.
|
||||||
|
LaunchedEffect(overlay) {
|
||||||
|
if (overlay != PlayerOverlay.Albums) albumSearch = ""
|
||||||
|
}
|
||||||
|
|
||||||
when (val s = screen) {
|
when (val s = screen) {
|
||||||
is AppScreen.Loading -> {
|
is AppScreen.Loading -> {
|
||||||
LoadingScreen()
|
LoadingScreen()
|
||||||
@@ -75,6 +100,18 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is AppScreen.Player -> {
|
is AppScreen.Player -> {
|
||||||
|
// The overlays form a back stack the OS knows nothing about (we don't use a
|
||||||
|
// nav library). Intercept the system/gesture back button and pop one level so
|
||||||
|
// it mirrors the on-screen back arrow, instead of falling through and closing
|
||||||
|
// the app. Only enabled when there's somewhere to pop back to.
|
||||||
|
BackHandler(enabled = overlay != PlayerOverlay.None) {
|
||||||
|
if (detailAlbum != null) {
|
||||||
|
detailAlbum = null
|
||||||
|
} else {
|
||||||
|
overlay = PlayerOverlay.None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (overlay) {
|
when (overlay) {
|
||||||
PlayerOverlay.Settings -> {
|
PlayerOverlay.Settings -> {
|
||||||
SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
|
SettingsScreen(vm, onBack = { overlay = PlayerOverlay.None })
|
||||||
@@ -95,6 +132,10 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
|||||||
} else {
|
} else {
|
||||||
AlbumsScreen(
|
AlbumsScreen(
|
||||||
vm,
|
vm,
|
||||||
|
query = albumSearch,
|
||||||
|
onQueryChange = { albumSearch = it },
|
||||||
|
listState = albumListState,
|
||||||
|
gridState = albumGridState,
|
||||||
onBack = { overlay = PlayerOverlay.None },
|
onBack = { overlay = PlayerOverlay.None },
|
||||||
onOpenAlbum = { detailAlbum = it },
|
onOpenAlbum = { detailAlbum = it },
|
||||||
)
|
)
|
||||||
@@ -145,6 +186,8 @@ private fun ConnectScreen(
|
|||||||
// that stick (rememberSaveable also survives rotation/process death).
|
// that stick (rememberSaveable also survives rotation/process death).
|
||||||
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
|
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
|
||||||
var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) }
|
var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) }
|
||||||
|
var password by rememberSaveable(saved.password) { mutableStateOf(saved.password ?: "") }
|
||||||
|
var showPassword by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
@@ -177,10 +220,32 @@ private fun ConnectScreen(
|
|||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Password (optional)") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation =
|
||||||
|
if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions =
|
||||||
|
androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showPassword = !showPassword }) {
|
||||||
|
Icon(
|
||||||
|
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||||
|
contentDescription = if (showPassword) "Hide password" else "Show password",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600) },
|
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600, password) },
|
||||||
enabled = host.isNotBlank(),
|
enabled = host.isNotBlank(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -12,11 +13,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
import androidx.compose.material.icons.automirrored.filled.QueueMusic
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Cast
|
import androidx.compose.material.icons.filled.Cast
|
||||||
import androidx.compose.material.icons.filled.LibraryMusic
|
import androidx.compose.material.icons.filled.LibraryMusic
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
@@ -26,9 +30,8 @@ import androidx.compose.material.icons.filled.Settings
|
|||||||
import androidx.compose.material.icons.filled.Shuffle
|
import androidx.compose.material.icons.filled.Shuffle
|
||||||
import androidx.compose.material.icons.filled.SkipNext
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
import androidx.compose.material.icons.filled.SkipPrevious
|
import androidx.compose.material.icons.filled.SkipPrevious
|
||||||
import androidx.compose.material.icons.filled.VolumeUp
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -45,15 +48,53 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
import ca.ksamad.encore.mpd.model.PlayerState
|
import ca.ksamad.encore.mpd.model.PlayerState
|
||||||
import ca.ksamad.encore.playback.SongArt
|
import ca.ksamad.encore.playback.SongArt
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/** Largest the flexible album art is allowed to grow, so it doesn't sprawl on tablets/foldables. */
|
||||||
|
private val ART_MAX_SIZE = 480.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum usable height at which we trust the whole screen fits without scrolling (and switch to the
|
||||||
|
* flexible, art-fills-the-slack layout). Below this — landscape, small phones — we scroll instead so
|
||||||
|
* the lower controls never get clipped.
|
||||||
|
*/
|
||||||
|
private val FLEX_MIN_HEIGHT = 740.dp
|
||||||
|
|
||||||
|
// --- Album-art colour-bleed tuning ---
|
||||||
|
// A 3-stop radial fade (centre → mid → base) with no flat plateau, so the colour fades continuously
|
||||||
|
// out from the cover. The strengths are how far the accent is blended toward the base (0 = base,
|
||||||
|
// 1 = full accent).
|
||||||
|
/** Accent blend at the cover centre (strongest). */
|
||||||
|
private const val BLEED_STRENGTH = 0.55f
|
||||||
|
|
||||||
|
/** Accent blend at [BLEED_MID_STOP] — the tail of the fade. */
|
||||||
|
private const val BLEED_MID_STRENGTH = 0.22f
|
||||||
|
|
||||||
|
/** Radius fraction at which the mid colour sits (shapes the falloff curve). */
|
||||||
|
private const val BLEED_MID_STOP = 0.5f
|
||||||
|
|
||||||
|
/** Gradient radius as a multiple of the cover's larger side — larger = more gradual, wider spread. */
|
||||||
|
private const val BLEED_RADIUS_FACTOR = 1.9f
|
||||||
|
|
||||||
|
/** Crossfade duration (ms) when the accent changes on a new song. */
|
||||||
|
private const val BLEED_ANIM_MS = 700
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The now-playing screen: current track, a live seek bar, transport controls, volume, and the
|
* The now-playing screen: current track, a live seek bar, transport controls, volume, and the
|
||||||
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
|
* repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever
|
||||||
@@ -68,14 +109,70 @@ fun NowPlayingScreen(
|
|||||||
) {
|
) {
|
||||||
val status by vm.status.collectAsStateWithLifecycle()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
val song by vm.currentSong.collectAsStateWithLifecycle()
|
val song by vm.currentSong.collectAsStateWithLifecycle()
|
||||||
val serverHost by vm.serverHost.collectAsStateWithLifecycle()
|
var showDisconnectConfirm by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
// --- Album-art colour bleed ------------------------------------------------
|
||||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
|
// Pull the cover's dominant colour and radiate it from the cover's on-screen position so the
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
// artwork looks like it bleeds into the page. Centre/radius come from the art's measured bounds
|
||||||
|
// (captureCover); the gradient draws edge-to-edge behind everything (before systemBarsPadding).
|
||||||
|
val base = MaterialTheme.colorScheme.background
|
||||||
|
val accent =
|
||||||
|
rememberArtAccentColor(song?.let { SongArt(it.uri, it.lastModified) }, fallback = base)
|
||||||
|
val animatedAccent by animateColorAsState(accent, tween(BLEED_ANIM_MS), label = "artAccent")
|
||||||
|
var coverCenter by remember { mutableStateOf(Offset.Unspecified) }
|
||||||
|
var coverRadius by remember { mutableFloatStateOf(0f) }
|
||||||
|
fun captureCover(coords: LayoutCoordinates) {
|
||||||
|
val pos = coords.positionInRoot()
|
||||||
|
val w = coords.size.width.toFloat()
|
||||||
|
val h = coords.size.height.toFloat()
|
||||||
|
coverCenter = Offset(pos.x + w / 2f, pos.y + h / 2f)
|
||||||
|
coverRadius = maxOf(w, h) * BLEED_RADIUS_FACTOR
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a tall enough screen, lay everything out without scrolling and let the album art
|
||||||
|
// flex into the leftover vertical space (capped at ART_MAX_SIZE) — bigger displays show
|
||||||
|
// bigger art, smaller ones just shrink it. On a short viewport (landscape, small phones)
|
||||||
|
// fall back to a scrolling column with a fixed art size so the lower controls stay reachable.
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.drawBehind {
|
||||||
|
val c = if (coverCenter.isSpecified) coverCenter else center
|
||||||
|
val r = if (coverRadius > 0f) coverRadius else size.minDimension
|
||||||
|
val centerColor = lerp(base, animatedAccent, BLEED_STRENGTH)
|
||||||
|
val midColor = lerp(base, animatedAccent, BLEED_MID_STRENGTH)
|
||||||
|
drawRect(
|
||||||
|
Brush.radialGradient(
|
||||||
|
0f to centerColor,
|
||||||
|
BLEED_MID_STOP to midColor,
|
||||||
|
1f to base,
|
||||||
|
center = c,
|
||||||
|
radius = r,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.systemBarsPadding()
|
||||||
) {
|
) {
|
||||||
// --- Top bar: library + settings ------------------------------------
|
val flexible = maxHeight >= FLEX_MIN_HEIGHT
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
// top = 0: the icon buttons carry their own internal padding, and systemBarsPadding
|
||||||
|
// already clears the status bar — any extra top padding just pushes them down.
|
||||||
|
val columnModifier =
|
||||||
|
if (flexible) {
|
||||||
|
Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, bottom = 12.dp)
|
||||||
|
} else {
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = columnModifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
// --- Top bar: disconnect (left) + queue/library/settings (right) ----
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = { showDisconnectConfirm = true }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
IconButton(onClick = onOpenQueue) {
|
IconButton(onClick = onOpenQueue) {
|
||||||
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
|
Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue")
|
||||||
}
|
}
|
||||||
@@ -87,14 +184,74 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Album art -------------------------------------------------------
|
// --- Album art ---------------------------------------------------
|
||||||
|
val artModel = song?.let { SongArt(it.uri, it.lastModified) }
|
||||||
|
if (flexible) {
|
||||||
|
// Fill the leftover height, kept square and capped by width/height/max.
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val side = minOf(maxWidth, maxHeight, ART_MAX_SIZE)
|
||||||
ArtImage(
|
ArtImage(
|
||||||
model = song?.uri?.let { SongArt(it) },
|
model = artModel,
|
||||||
iconSize = 96.dp,
|
iconSize = 96.dp,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.padding(vertical = 16.dp).size(240.dp).clip(RoundedCornerShape(16.dp)),
|
Modifier.size(side)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.onGloballyPositioned(::captureCover),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ArtImage(
|
||||||
|
model = artModel,
|
||||||
|
iconSize = 96.dp,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(vertical = 16.dp)
|
||||||
|
.fillMaxWidth(0.9f)
|
||||||
|
.aspectRatio(1f)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.onGloballyPositioned(::captureCover),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlaybackDetails(vm = vm, status = status, song = song)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDisconnectConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDisconnectConfirm = false },
|
||||||
|
title = { Text("Disconnect?") },
|
||||||
|
text = { Text("Disconnect from the server and return to the connect screen.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDisconnectConfirm = false
|
||||||
|
vm.disconnect()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything below the album art: track metadata, the seek bar, transport, volume, and the option
|
||||||
|
* chips. Emitted straight into the caller's centered [Column] so it's shared by both the flexible
|
||||||
|
* (no-scroll) and scrolling layouts.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PlaybackDetails(
|
||||||
|
vm: PlayerViewModel,
|
||||||
|
status: MpdStatus?,
|
||||||
|
song: MpdSong?,
|
||||||
|
) {
|
||||||
// --- Track metadata --------------------------------------------------
|
// --- Track metadata --------------------------------------------------
|
||||||
Text(
|
Text(
|
||||||
text = song?.title ?: song?.uri ?: "Nothing playing",
|
text = song?.title ?: song?.uri ?: "Nothing playing",
|
||||||
@@ -138,18 +295,33 @@ fun NowPlayingScreen(
|
|||||||
|
|
||||||
AudioPropertyPills(status = status)
|
AudioPropertyPills(status = status)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// --- Transport -------------------------------------------------------
|
// --- Transport (repeat · prev · play/pause · next · shuffle) ---------
|
||||||
|
// Repeat and shuffle are icon-only toggles — their icons are well-known, so no
|
||||||
|
// label is needed. They tint to the primary colour when on, muted when off.
|
||||||
val playing = status?.state == PlayerState.PLAY
|
val playing = status?.state == PlayerState.PLAY
|
||||||
|
val repeatOn = status?.repeat == true
|
||||||
|
val shuffleOn = status?.random == true
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
|
IconButton(onClick = { vm.setRepeat(!repeatOn) }, modifier = Modifier.size(48.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Repeat,
|
||||||
|
contentDescription = if (repeatOn) "Repeat on" else "Repeat off",
|
||||||
|
tint =
|
||||||
|
if (repeatOn) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
|
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.SkipPrevious,
|
Icons.Filled.SkipPrevious,
|
||||||
@@ -171,49 +343,21 @@ fun NowPlayingScreen(
|
|||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
IconButton(onClick = { vm.setRandom(!shuffleOn) }, modifier = Modifier.size(48.dp)) {
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// CastIndicator(host = serverHost)
|
|
||||||
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// --- Playback options ------------------------------------------------
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
FilterChip(
|
|
||||||
selected = status?.repeat == true,
|
|
||||||
onClick = { vm.setRepeat(status?.repeat != true) },
|
|
||||||
label = { Text("Repeat") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Repeat,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = status?.random == true,
|
|
||||||
onClick = { vm.setRandom(status?.random != true) },
|
|
||||||
label = { Text("Shuffle") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Shuffle,
|
Icons.Filled.Shuffle,
|
||||||
contentDescription = null,
|
contentDescription = if (shuffleOn) "Shuffle on" else "Shuffle off",
|
||||||
modifier = Modifier.size(18.dp),
|
tint =
|
||||||
)
|
if (shuffleOn) MaterialTheme.colorScheme.primary
|
||||||
},
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
|
|
||||||
TextButton(onClick = vm::disconnect) {
|
|
||||||
Text("Disconnect")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,72 +412,16 @@ private fun SeekBar(
|
|||||||
* (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's
|
* (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's
|
||||||
* no format info (e.g. stopped).
|
* no format info (e.g. stopped).
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AudioPropertyPills(status: MpdStatus?) {
|
private fun AudioPropertyPills(status: MpdStatus?) {
|
||||||
val pills = remember(status?.audio, status?.bitrate) { audioPropertyPills(status) }
|
val pills =
|
||||||
if (pills.isEmpty()) return
|
remember(status?.audio, status?.bitrate) {
|
||||||
|
buildList {
|
||||||
FlowRow(
|
addAll(audioFormatPills(status?.audio))
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
status?.bitrate?.takeIf { it > 0 }?.let { add("$it kbps") }
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
pills.forEach { pill ->
|
|
||||||
Text(
|
|
||||||
text = pill,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier =
|
|
||||||
Modifier.clip(RoundedCornerShape(50))
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
|
||||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
AudioFormatPills(pills = pills, modifier = Modifier.padding(top = 10.dp))
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the audio-format pill labels from a status snapshot. MPD's `audio` field is
|
|
||||||
* `samplerate:bits:channels` (e.g. `"44100:16:2"`), where the middle token can be `f` (float) or
|
|
||||||
* `dsd` rather than a bit depth. Only well-formed parts appear.
|
|
||||||
*/
|
|
||||||
private fun audioPropertyPills(status: MpdStatus?): List<String> {
|
|
||||||
status ?: return emptyList()
|
|
||||||
val pills = mutableListOf<String>()
|
|
||||||
|
|
||||||
status.audio
|
|
||||||
?.split(":")
|
|
||||||
?.takeIf { it.size >= 3 }
|
|
||||||
?.let { parts ->
|
|
||||||
parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) }
|
|
||||||
formatSampleFormat(parts[1])?.let { pills.add(it) }
|
|
||||||
formatChannels(parts[2])?.let { pills.add(it) }
|
|
||||||
}
|
|
||||||
status.bitrate?.takeIf { it > 0 }?.let { pills.add("$it kbps") }
|
|
||||||
|
|
||||||
return pills
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatSampleRate(hz: Int): String {
|
|
||||||
val khz = hz / 1000.0
|
|
||||||
val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz)
|
|
||||||
return "$value kHz"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatSampleFormat(token: String): String? =
|
|
||||||
when (token) {
|
|
||||||
"f" -> "Float"
|
|
||||||
"dsd" -> "DSD"
|
|
||||||
else -> token.toIntOrNull()?.let { "$it-bit" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatChannels(token: String): String? =
|
|
||||||
when (token.toIntOrNull()) {
|
|
||||||
null -> null
|
|
||||||
1 -> "Mono"
|
|
||||||
2 -> "Stereo"
|
|
||||||
else -> "$token ch"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
|
/** "Casting" affordance: signals that the volume below controls the server, not the device. */
|
||||||
@@ -372,7 +460,7 @@ private fun VolumeControl(
|
|||||||
|
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.VolumeUp,
|
Icons.AutoMirrored.Filled.VolumeUp,
|
||||||
contentDescription = "Volume",
|
contentDescription = "Volume",
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import ca.ksamad.encore.EncoreApplication
|
import ca.ksamad.encore.EncoreApplication
|
||||||
|
import ca.ksamad.encore.data.AlbumViewMode
|
||||||
import ca.ksamad.encore.data.ConnectionSettings
|
import ca.ksamad.encore.data.ConnectionSettings
|
||||||
import ca.ksamad.encore.mpd.MpdConnectionState
|
import ca.ksamad.encore.mpd.MpdConnectionState
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -39,6 +40,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val currentSong = manager.currentSong
|
val currentSong = manager.currentSong
|
||||||
val serverHost = manager.serverHost
|
val serverHost = manager.serverHost
|
||||||
val settings = manager.settings
|
val settings = manager.settings
|
||||||
|
val albumViewMode = manager.albumViewMode
|
||||||
|
val volumeStep = manager.volumeStep
|
||||||
|
val gridColumns = manager.gridColumns
|
||||||
|
|
||||||
val screen: StateFlow<AppScreen> =
|
val screen: StateFlow<AppScreen> =
|
||||||
combine(
|
combine(
|
||||||
@@ -64,7 +68,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
fun connect(
|
fun connect(
|
||||||
host: String,
|
host: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
) = manager.connect(host, port)
|
password: String? = null,
|
||||||
|
) = manager.connect(host, port, password)
|
||||||
|
|
||||||
fun disconnect() = manager.disconnect()
|
fun disconnect() = manager.disconnect()
|
||||||
|
|
||||||
@@ -86,6 +91,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
fun setConsume(on: Boolean) = manager.setConsume(on)
|
fun setConsume(on: Boolean) = manager.setConsume(on)
|
||||||
|
|
||||||
|
fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode)
|
||||||
|
|
||||||
|
fun setVolumeStep(step: Int) = manager.setVolumeStep(step)
|
||||||
|
|
||||||
|
fun setGridColumns(columns: Int) = manager.setGridColumns(columns)
|
||||||
|
|
||||||
fun playAlbum(
|
fun playAlbum(
|
||||||
album: String,
|
album: String,
|
||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
@@ -101,6 +112,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
albumArtist: String?,
|
albumArtist: String?,
|
||||||
) = manager.playAlbumNext(album, albumArtist)
|
) = manager.playAlbumNext(album, albumArtist)
|
||||||
|
|
||||||
|
fun playTracks(uris: List<String>) = manager.playTracks(uris)
|
||||||
|
|
||||||
fun queueTrack(uri: String) = manager.queueTrack(uri)
|
fun queueTrack(uri: String) = manager.queueTrack(uri)
|
||||||
|
|
||||||
fun playTrackNext(uri: String) = manager.playTrackNext(uri)
|
fun playTrackNext(uri: String) = manager.playTrackNext(uri)
|
||||||
@@ -112,6 +125,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
suspend fun loadAlbums() = manager.loadAlbums()
|
suspend fun loadAlbums() = manager.loadAlbums()
|
||||||
|
|
||||||
|
fun updateDatabase() = manager.updateDatabase()
|
||||||
|
|
||||||
|
fun rescanDatabase() = manager.rescanDatabase()
|
||||||
|
|
||||||
|
fun refreshArtwork() = manager.refreshArtwork()
|
||||||
|
|
||||||
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
|
||||||
|
|
||||||
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.DeleteSweep
|
import androidx.compose.material.icons.filled.DeleteSweep
|
||||||
import androidx.compose.material.icons.filled.VolumeUp
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -50,6 +50,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import ca.ksamad.encore.mpd.model.MpdSong
|
import ca.ksamad.encore.mpd.model.MpdSong
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||||
|
import ca.ksamad.encore.playback.AlbumArt
|
||||||
|
import ca.ksamad.encore.playback.MpdArtData
|
||||||
import ca.ksamad.encore.playback.SongArt
|
import ca.ksamad.encore.playback.SongArt
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -220,6 +222,17 @@ fun QueueScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Art model for a queue row. Keying by album — not the per-track URI — means every track of the
|
||||||
|
* same album resolves to one cache entry, so a 12-track album is fetched from the server once
|
||||||
|
* instead of twelve times, and reuses whatever the album library already cached. Falls back to the
|
||||||
|
* track's own art only for entries with no album tag (e.g. a loose file or a stream).
|
||||||
|
*/
|
||||||
|
private fun queueArt(song: MpdSong): MpdArtData =
|
||||||
|
song.album
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { AlbumArt(it, song.albumArtist) } ?: SongArt(song.uri, song.lastModified)
|
||||||
|
|
||||||
/** A single queue entry — art, title/artist, and a duration or now-playing badge. */
|
/** A single queue entry — art, title/artist, and a duration or now-playing badge. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRow(
|
private fun QueueRow(
|
||||||
@@ -237,7 +250,7 @@ private fun QueueRow(
|
|||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
ArtImage(
|
ArtImage(
|
||||||
model = SongArt(song.uri),
|
model = queueArt(song),
|
||||||
iconSize = 20.dp,
|
iconSize = 20.dp,
|
||||||
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)),
|
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)),
|
||||||
)
|
)
|
||||||
@@ -256,7 +269,7 @@ private fun QueueRow(
|
|||||||
},
|
},
|
||||||
trailingContent =
|
trailingContent =
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
{ Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") }
|
{ Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = "Now playing") }
|
||||||
} else {
|
} else {
|
||||||
song.duration?.let { { Text(formatDuration(it)) } }
|
song.duration?.let { { Text(formatDuration(it)) } }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Remove
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -23,6 +32,11 @@ import androidx.compose.material3.ListItem
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -32,14 +46,20 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import ca.ksamad.encore.data.AlbumViewMode
|
||||||
|
import ca.ksamad.encore.data.SettingsRepository
|
||||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings: shows the current server and offers to switch servers or reset all saved settings.
|
* Settings: shows the current server and offers to switch servers or reset all saved settings.
|
||||||
@@ -54,6 +74,15 @@ fun SettingsScreen(
|
|||||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||||
val status by vm.status.collectAsStateWithLifecycle()
|
val status by vm.status.collectAsStateWithLifecycle()
|
||||||
var confirmReset by remember { mutableStateOf(false) }
|
var confirmReset by remember { mutableStateOf(false) }
|
||||||
|
var refreshMenuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun flash(message: String) {
|
||||||
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
|
scope.launch { snackbarHostState.showSnackbar(message) }
|
||||||
|
}
|
||||||
|
|
||||||
// Server statistics (`stats`), fetched once when the screen opens. `null`
|
// Server statistics (`stats`), fetched once when the screen opens. `null`
|
||||||
// means either still loading or unavailable — [statsLoaded] disambiguates.
|
// means either still loading or unavailable — [statsLoaded] disambiguates.
|
||||||
@@ -74,7 +103,8 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -119,6 +149,159 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// How far the hardware volume keys move the server volume per press.
|
||||||
|
val volumeStep by vm.volumeStep.collectAsStateWithLifecycle()
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Volume step") },
|
||||||
|
supportingContent = { Text("How much each volume-key press changes the volume") },
|
||||||
|
trailingContent = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { vm.setVolumeStep(volumeStep - 1) },
|
||||||
|
enabled = volumeStep > SettingsRepository.MIN_VOLUME_STEP,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Remove, contentDescription = "Decrease volume step")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"$volumeStep%",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.width(40.dp),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { vm.setVolumeStep(volumeStep + 1) },
|
||||||
|
enabled = volumeStep < SettingsRepository.MAX_VOLUME_STEP,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = "Increase volume step")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Library",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 8.dp, top = 20.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
val albumViewMode by vm.albumViewMode.collectAsStateWithLifecycle()
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Album view") },
|
||||||
|
supportingContent = { Text("How the albums library is laid out") },
|
||||||
|
trailingContent = {
|
||||||
|
SingleChoiceSegmentedButtonRow {
|
||||||
|
AlbumViewMode.entries.forEachIndexed { index, mode ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = albumViewMode == mode,
|
||||||
|
onClick = { vm.setAlbumViewMode(mode) },
|
||||||
|
shape =
|
||||||
|
SegmentedButtonDefaults.itemShape(
|
||||||
|
index = index,
|
||||||
|
count = AlbumViewMode.entries.size,
|
||||||
|
),
|
||||||
|
icon = {},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
when (mode) {
|
||||||
|
AlbumViewMode.List -> "List"
|
||||||
|
AlbumViewMode.Grid -> "Grid"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Column count only matters for the grid layout, so only offer it there.
|
||||||
|
if (albumViewMode == AlbumViewMode.Grid) {
|
||||||
|
val gridColumns by vm.gridColumns.collectAsStateWithLifecycle()
|
||||||
|
val columnOptions =
|
||||||
|
SettingsRepository.MIN_GRID_COLUMNS..SettingsRepository.MAX_GRID_COLUMNS
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Grid columns") },
|
||||||
|
supportingContent = { Text("Number of albums per row") },
|
||||||
|
trailingContent = {
|
||||||
|
SingleChoiceSegmentedButtonRow {
|
||||||
|
columnOptions.forEachIndexed { index, columns ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = gridColumns == columns,
|
||||||
|
onClick = { vm.setGridColumns(columns) },
|
||||||
|
shape =
|
||||||
|
SegmentedButtonDefaults.itemShape(
|
||||||
|
index = index,
|
||||||
|
count = columnOptions.count(),
|
||||||
|
),
|
||||||
|
icon = {},
|
||||||
|
) {
|
||||||
|
Text(columns.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the server to rescan its music sources. Tap runs a normal (mtime-based)
|
||||||
|
// update; the overflow offers a full rescan that also re-reads unchanged files.
|
||||||
|
// While a scan runs, `status.updating` drives the "Refreshing…" state.
|
||||||
|
val refreshing = status?.updating == true
|
||||||
|
ListItem(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable(enabled = !refreshing) {
|
||||||
|
vm.updateDatabase()
|
||||||
|
flash("Library refresh started")
|
||||||
|
},
|
||||||
|
headlineContent = { Text("Refresh library") },
|
||||||
|
supportingContent = {
|
||||||
|
Text(if (refreshing) "Refreshing…" else "Scan for new and changed files")
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (refreshing) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||||
|
} else {
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { refreshMenuOpen = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MoreVert,
|
||||||
|
contentDescription = "More refresh options",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = refreshMenuOpen,
|
||||||
|
onDismissRequest = { refreshMenuOpen = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Full rescan") },
|
||||||
|
onClick = {
|
||||||
|
refreshMenuOpen = false
|
||||||
|
vm.rescanDatabase()
|
||||||
|
flash("Full rescan started")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drop Encore's cached covers so corrected art on the server shows up. Most
|
||||||
|
// re-tagged/re-imported tracks refresh on their own (their mtime changes the
|
||||||
|
// art's cache key); this is for the rest — album covers and bare cover.jpg
|
||||||
|
// swaps that don't bump a track's mtime.
|
||||||
|
ListItem(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
vm.refreshArtwork()
|
||||||
|
flash("Artwork cache cleared — covers will reload")
|
||||||
|
},
|
||||||
|
headlineContent = { Text("Refresh artwork") },
|
||||||
|
supportingContent = { Text("Reload cover art from the server") },
|
||||||
|
trailingContent = {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Server statistics",
|
"Server statistics",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
|||||||
@@ -1,82 +1,122 @@
|
|||||||
package ca.ksamad.encore.ui
|
package ca.ksamad.encore.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SwipeToDismissBox
|
|
||||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue]
|
* A swipe only fires when the finger is still moving horizontally faster than this on release — a
|
||||||
* (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows
|
* deliberate flick. A slow drag (e.g. the small sideways drift while scrolling the list up and down)
|
||||||
|
* settles back without triggering, which is the whole point.
|
||||||
|
*/
|
||||||
|
private val SWIPE_VELOCITY_THRESHOLD = 450.dp // per second
|
||||||
|
|
||||||
|
/** How far a row can be dragged sideways (enough to show the reveal, then it clamps). */
|
||||||
|
private val SWIPE_MAX_REVEAL = 96.dp
|
||||||
|
|
||||||
|
/** Minimum sideways travel before a flick counts, so a fast stationary twitch can't trigger it. */
|
||||||
|
private val SWIPE_MIN_DISTANCE = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps [content] in the app's shared "queue swipe" affordance: flick right to [onAddToQueue]
|
||||||
|
* (append), flick left to [onPlayNext] (insert after the current track). Used for both album rows
|
||||||
* and individual tracks so the gesture and its meaning stay identical wherever it appears.
|
* and individual tracks so the gesture and its meaning stay identical wherever it appears.
|
||||||
*
|
*
|
||||||
* Both are one-shot actions, not deletions, so the row always springs back and stays in the list —
|
* Unlike a dismiss-style swipe, committing is gated on **release velocity**, not distance — so
|
||||||
* [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once settled.
|
* accidental sideways movement while scrolling vertically slides a little and springs back instead
|
||||||
|
* of firing. [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once
|
||||||
|
* settled at rest.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QueueSwipeRow(
|
fun QueueSwipeRow(
|
||||||
onAddToQueue: () -> Unit,
|
onAddToQueue: () -> Unit,
|
||||||
onPlayNext: () -> Unit,
|
onPlayNext: () -> Unit,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state =
|
val scope = rememberCoroutineScope()
|
||||||
rememberSwipeToDismissBoxState(
|
val offsetX = remember { Animatable(0f) }
|
||||||
confirmValueChange = { target ->
|
|
||||||
when (target) {
|
|
||||||
SwipeToDismissBoxValue.StartToEnd -> {
|
|
||||||
onAddToQueue()
|
|
||||||
}
|
|
||||||
|
|
||||||
SwipeToDismissBoxValue.EndToStart -> {
|
val density = LocalDensity.current
|
||||||
onPlayNext()
|
val velocityThresholdPx = with(density) { SWIPE_VELOCITY_THRESHOLD.toPx() }
|
||||||
}
|
val maxRevealPx = with(density) { SWIPE_MAX_REVEAL.toPx() }
|
||||||
|
val minDistancePx = with(density) { SWIPE_MIN_DISTANCE.toPx() }
|
||||||
|
|
||||||
SwipeToDismissBoxValue.Settled -> {}
|
Box(
|
||||||
}
|
modifier =
|
||||||
false // Never settle to dismissed — snap back and keep the row.
|
Modifier.pointerInput(Unit) {
|
||||||
|
val tracker = VelocityTracker()
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { tracker.resetTracking() },
|
||||||
|
onHorizontalDrag = { change, dragAmount ->
|
||||||
|
tracker.addPosition(change.uptimeMillis, change.position)
|
||||||
|
val target = (offsetX.value + dragAmount).coerceIn(-maxRevealPx, maxRevealPx)
|
||||||
|
scope.launch { offsetX.snapTo(target) }
|
||||||
|
change.consume()
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val velocity = tracker.calculateVelocity().x
|
||||||
|
val offset = offsetX.value
|
||||||
|
// A deliberate flick: fast enough, far enough, and the flick and the
|
||||||
|
// drag point the same way (so a bounce-back release doesn't count).
|
||||||
|
val committed =
|
||||||
|
abs(velocity) >= velocityThresholdPx &&
|
||||||
|
abs(offset) >= minDistancePx &&
|
||||||
|
(velocity > 0f) == (offset > 0f)
|
||||||
|
if (committed) {
|
||||||
|
if (offset > 0f) onAddToQueue() else onPlayNext()
|
||||||
}
|
}
|
||||||
|
scope.launch { offsetX.animateTo(0f) }
|
||||||
|
},
|
||||||
|
onDragCancel = { scope.launch { offsetX.animateTo(0f) } },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
SwipeToDismissBox(
|
|
||||||
state = state,
|
|
||||||
backgroundContent = { SwipeActionBackground(state.dismissDirection) },
|
|
||||||
) {
|
) {
|
||||||
content()
|
SwipeActionBackground(offset = offsetX.value, modifier = Modifier.matchParentSize())
|
||||||
|
Box(modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }) { content() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (swipe
|
* The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (drag
|
||||||
* right) and a "Play next" hint on the trailing edge (swipe left). Renders empty while the row is
|
* right, [offset] > 0) and a "Play next" hint on the trailing edge (drag left, [offset] < 0).
|
||||||
* settled.
|
* Renders empty while the row is settled at rest.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
private fun SwipeActionBackground(
|
||||||
if (direction == SwipeToDismissBoxValue.Settled) {
|
offset: Float,
|
||||||
Box(Modifier.fillMaxSize())
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (offset == 0f) {
|
||||||
|
Box(modifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val queueing = direction == SwipeToDismissBoxValue.StartToEnd
|
val queueing = offset > 0f
|
||||||
val container =
|
val container =
|
||||||
if (queueing) {
|
if (queueing) {
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
@@ -93,7 +133,7 @@ private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) {
|
|||||||
val label = if (queueing) "Add to queue" else "Play next"
|
val label = if (queueing) "Add to queue" else "Play next"
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(container).padding(horizontal = 24.dp),
|
modifier.background(container).padding(horizontal = 24.dp),
|
||||||
contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd,
|
contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd,
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 521 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 683 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1007 B |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Android 11 and below (fullBackupContent). Exclude the connection-settings
|
||||||
|
DataStore so the saved server + password never leave the device in a backup.
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Android 12+ (dataExtractionRules). Keep the connection-settings DataStore
|
||||||
|
(saved server + password) out of both cloud backups and device-to-device
|
||||||
|
transfers.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="file" path="datastore/connection_settings.preferences_pb" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -37,6 +37,7 @@ class MpdModelTest {
|
|||||||
assertEquals(3, s.song)
|
assertEquals(3, s.song)
|
||||||
assertEquals(42.5, s.elapsed!!, 0.0001)
|
assertEquals(42.5, s.elapsed!!, 0.0001)
|
||||||
assertEquals(215.3, s.duration!!, 0.0001)
|
assertEquals(215.3, s.duration!!, 0.0001)
|
||||||
|
assertFalse(s.updating) // no updating_db field → not scanning
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -47,6 +48,12 @@ class MpdModelTest {
|
|||||||
assertEquals(0, s.playlistLength)
|
assertEquals(0, s.playlistLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun status_updatingDbFlagsScanInProgress() {
|
||||||
|
val s = MpdStatus.from(mapOf("state" to "stop", "updating_db" to "3"))
|
||||||
|
assertTrue(s.updating)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun song_parsesTagsAndDuration() {
|
fun song_parsesTagsAndDuration() {
|
||||||
val song =
|
val song =
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
First release.
|
||||||
|
|
||||||
|
* Connect to an MPD server and control playback
|
||||||
|
* Browse albums as a list or grid, with cover art and search
|
||||||
|
* Manage the play queue with swipe actions
|
||||||
|
* Media notification and hardware volume-key support
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
Encore is a remote control for a Music Player Daemon (MPD) server on your local network. It focuses on everyday playback: see what's playing, control the queue, and browse your library.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
|
||||||
|
* Browse your library by album, as a list or a two-column grid
|
||||||
|
* Album and track cover art
|
||||||
|
* Search albums and artists
|
||||||
|
* View the play queue; add whole albums or single tracks
|
||||||
|
* Swipe an album or track to queue it or play it next
|
||||||
|
* Playback controls with a seek bar
|
||||||
|
* Adjust server volume, including with the hardware volume keys
|
||||||
|
* Media notification and lock-screen controls
|
||||||
|
* Multi-disc album support and server statistics
|
||||||
|
* Consume mode toggle
|
||||||
|
|
||||||
|
Encore talks directly to the MPD server you configure. It uses no cloud service, no accounts, and no tracking or analytics.
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 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
|
media = "1.7.0" # MediaSessionCompat: OS media session, notification, cast-style volume
|
||||||
coil = "3.0.4" # Coil: image loading + memory/disk caching for album art
|
coil = "3.0.4" # Coil: image loading + memory/disk caching for album art
|
||||||
# (pinned to a 3.x that targets compileSdk 35; 3.5 needs 36)
|
# (pinned to a 3.x that targets compileSdk 35; 3.5 needs 36)
|
||||||
|
palette = "1.0.0" # AndroidX Palette: extract cover-art colours for the bleed effect
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -32,6 +33,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
|||||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -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
|
||||||