Initial MVP
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Android
|
||||
*.apk
|
||||
*.aab
|
||||
local.properties
|
||||
|
||||
# Nix
|
||||
result
|
||||
result-*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.kotlin/
|
||||
|
||||
# Environment mappings
|
||||
.direnv/
|
||||
@@ -0,0 +1,108 @@
|
||||
# Music Remote
|
||||
|
||||
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
||||
tested with **Nix**.
|
||||
|
||||
Jetpack Compose *is* the modern Android UI framework — declarative Kotlin UI that has
|
||||
replaced the old XML layouts. Material 3 is Google's current design system, wired up here
|
||||
with dynamic (wallpaper-based) color on Android 12+.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
flake.nix Nix entrypoint: dev shells, SDK, package, emulator
|
||||
package.nix Reproducible APK build (nixpkgs-style derivation)
|
||||
deps.json Lockfile of Gradle/Maven deps (generated — see below)
|
||||
settings.gradle.kts Gradle project + repositories
|
||||
build.gradle.kts Root build script (declares plugin versions)
|
||||
gradle/libs.versions.toml Version catalog: all dependency/plugin versions
|
||||
app/
|
||||
build.gradle.kts The :app module (Android + Compose config)
|
||||
src/main/
|
||||
AndroidManifest.xml App/activity declaration
|
||||
kotlin/ca/ksamad/musicremote/
|
||||
MainActivity.kt Entry activity; sets the Compose content
|
||||
Theme.kt Material 3 theme (dynamic color)
|
||||
res/values/ strings.xml, themes.xml
|
||||
src/test/kotlin/... A plain JVM unit test
|
||||
```
|
||||
|
||||
## Everyday development (recommended)
|
||||
|
||||
Use the dev shell — it puts a JDK, Gradle, and the Android SDK on your PATH with the
|
||||
right environment variables set:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
gradle assembleDebug # build the debug APK -> app/build/outputs/apk/debug/
|
||||
gradle testDebugUnitTest # run unit tests
|
||||
gradle tasks # see everything available
|
||||
```
|
||||
|
||||
The debug APK is unsigned but installable on a device/emulator:
|
||||
|
||||
```sh
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Android Studio
|
||||
|
||||
For the GUI editor, Compose previews, and device manager:
|
||||
|
||||
```sh
|
||||
nix develop .#studio
|
||||
android-studio
|
||||
```
|
||||
|
||||
Open this folder as an existing project. (Android Studio manages its own SDK; the
|
||||
command-line flow above is fully independent of it.)
|
||||
|
||||
### Emulator
|
||||
|
||||
```sh
|
||||
nix run .#emulate
|
||||
```
|
||||
|
||||
## Reproducible build with Nix
|
||||
|
||||
```sh
|
||||
nix build # -> ./result/music-remote.apk
|
||||
```
|
||||
|
||||
Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven
|
||||
dependency is pinned by hash in `deps.json`, and Nix supplies the Android SDK. It also
|
||||
runs the unit tests (`testDebugUnitTest`) as its check phase, so a green `nix build` means
|
||||
both "it assembles" and "tests pass". This is what you'd use in CI for a reproducible
|
||||
artifact.
|
||||
|
||||
> Note: `package.nix` and `app/build.gradle.kts` contain a few small workarounds, active
|
||||
> **only** during dependency capture (gated on the `IN_GRADLE_UPDATE_DEPS` env var), that
|
||||
> stop Nix's "resolve every configuration" sweep from tripping over internal AGP/Kotlin
|
||||
> configurations. They don't affect the dev shell or the actual build. See the comments in
|
||||
> `app/build.gradle.kts` for the details.
|
||||
|
||||
### Regenerating `deps.json`
|
||||
|
||||
Any time you change a dependency or plugin version (i.e. edit
|
||||
`gradle/libs.versions.toml` or a `build.gradle.kts`), regenerate the lockfile:
|
||||
|
||||
```sh
|
||||
nix build .#default.mitmCache.updateScript
|
||||
./result # runs the build under a proxy, rewrites ./deps.json
|
||||
```
|
||||
|
||||
Then commit the updated `deps.json`. (It works by recording Gradle's dependency
|
||||
downloads through a man-in-the-middle proxy and storing their hashes — see
|
||||
`package.nix`.)
|
||||
|
||||
## Docs
|
||||
|
||||
- [`docs/device-testing.md`](docs/device-testing.md) — build & install onto a
|
||||
real Android device/DAP over USB (and the NixOS `adb` notes).
|
||||
- [`docs/TODO.md`](docs/TODO.md) — near-term roadmap / known bugs.
|
||||
|
||||
## Version notes
|
||||
|
||||
Pinned in `gradle/libs.versions.toml`: AGP 8.7.3, Kotlin 2.0.21, Compose BOM 2024.10.01,
|
||||
compiled against `compileSdk` 35, `minSdk` 24. Nix supplies Gradle 8.14. If you bump
|
||||
these, keep AGP/Kotlin/Gradle mutually compatible and regenerate `deps.json`.
|
||||
@@ -0,0 +1,112 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ca.ksamad.musicremote"
|
||||
compileSdk = 35
|
||||
|
||||
// Pin the build-tools to exactly what Nix supplies. Without this, AGP picks
|
||||
// its own default version and tries to auto-install it into the read-only
|
||||
// Nix SDK, which fails. Keep this in sync with flake.nix's buildToolsVersion.
|
||||
buildToolsVersion = "35.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ca.ksamad.musicremote"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
// Surface `println`/stdout from unit tests in the Gradle console.
|
||||
// Handy for the gated MPD integration test (see MpdServerIntegrationTest).
|
||||
it.testLogging {
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nix reproducible-build support -----------------------------------------
|
||||
// The Nix dependency-capture step (`nixDownloadDeps`) resolves *every* resolvable
|
||||
// configuration at once to record what Gradle downloads. Several AGP/Kotlin
|
||||
// configurations aren't meant to be resolved that way and break the sweep:
|
||||
// * `*DependenciesMetadata` (Kotlin) resolve with no version under a BOM.
|
||||
// * the unit-/android-test classpaths carry a `:app -> :app` self-dependency
|
||||
// that is ambiguous without AGP's own artifact-view based resolution.
|
||||
// These tweaks apply ONLY during that capture step, detected via the
|
||||
// IN_GRADLE_UPDATE_DEPS env var the update script sets. Ordinary builds — in the
|
||||
// dev shell or the real `nix build` (assembleDebug) — see none of this and use
|
||||
// 100% standard Android configuration, including their own test resolution.
|
||||
if (System.getenv("IN_GRADLE_UPDATE_DEPS") == "1") {
|
||||
// Drop the test components entirely for the sweep, so their compile/runtime
|
||||
// classpaths (which carry the ambiguous `:app -> :app` self-dependency) are
|
||||
// never created. Removing the component is clean; merely marking those
|
||||
// classpaths non-resolvable instead makes the Kotlin plugin fail.
|
||||
androidComponents {
|
||||
beforeVariants(selector().all()) { variantBuilder ->
|
||||
variantBuilder.enableAndroidTest = false
|
||||
variantBuilder.enableUnitTest = false
|
||||
}
|
||||
}
|
||||
|
||||
// Kotlin's `*DependenciesMetadata` configs resolve with no version under a
|
||||
// BOM; they pull nothing the real classpaths don't, so skip them.
|
||||
configurations.configureEach {
|
||||
if (name.endsWith("DependenciesMetadata")) {
|
||||
isCanBeResolved = false
|
||||
}
|
||||
}
|
||||
|
||||
// With the unit-test component gone, still record the test-only artifacts
|
||||
// (JUnit) via a clean configuration that has no project self-dependency, so
|
||||
// the captured lockfile is complete enough for `testDebugUnitTest` to run
|
||||
// offline during `nix build`.
|
||||
val nixCaptureTestDeps = configurations.create("nixCaptureTestDeps")
|
||||
dependencies.addProvider(nixCaptureTestDeps.name, libs.junit)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// The Compose BOM aligns every Compose artifact to one tested version set,
|
||||
// so the individual Compose deps below are declared without versions.
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Needed to open a TCP socket to the MPD server over the network. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MusicRemote">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,34 @@
|
||||
package ca.ksamad.musicremote
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import ca.ksamad.musicremote.ui.MusicRemoteApp
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MusicRemoteTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
MusicRemoteApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ca.ksamad.musicremote
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Material 3 theme for the app.
|
||||
*
|
||||
* On Android 12+ it uses "dynamic color" (the palette derived from the user's
|
||||
* wallpaper); on older versions it falls back to a default light/dark scheme.
|
||||
*/
|
||||
@Composable
|
||||
fun MusicRemoteTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ca.ksamad.musicremote.data
|
||||
|
||||
/** The persisted MPD server the app connects to. */
|
||||
data class ConnectionSettings(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
) {
|
||||
companion object {
|
||||
/** First-run defaults, used until the user connects at least once. */
|
||||
val DEFAULT = ConnectionSettings(host = "192.168.2.148", port = 6600)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package ca.ksamad.musicremote.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
// A single process-wide DataStore instance, tied to the application context via
|
||||
// this property delegate (the recommended pattern — creating more than one
|
||||
// DataStore for the same file throws).
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "connection_settings")
|
||||
|
||||
/**
|
||||
* Reads and writes the persisted [ConnectionSettings] using Preferences
|
||||
* DataStore. Reads come back as a [Flow] that emits on every change; the write
|
||||
* is a `suspend` transaction.
|
||||
*/
|
||||
class SettingsRepository(private val context: Context) {
|
||||
|
||||
private object Keys {
|
||||
val HOST = stringPreferencesKey("host")
|
||||
val PORT = intPreferencesKey("port")
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved settings, or `null` if the user has never connected (no host
|
||||
* persisted yet). Callers use the null case to show first-run UI / decide
|
||||
* whether to auto-connect.
|
||||
*/
|
||||
val settingsOrNull: Flow<ConnectionSettings?> = context.dataStore.data.map { prefs ->
|
||||
val host = prefs[Keys.HOST] ?: return@map null
|
||||
ConnectionSettings(host = host, port = prefs[Keys.PORT] ?: ConnectionSettings.DEFAULT.port)
|
||||
}
|
||||
|
||||
/** Same as [settingsOrNull] but falling back to [ConnectionSettings.DEFAULT] for form prefill. */
|
||||
val settings: Flow<ConnectionSettings> = settingsOrNull.map { it ?: ConnectionSettings.DEFAULT }
|
||||
|
||||
suspend fun save(settings: ConnectionSettings) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[Keys.HOST] = settings.host
|
||||
prefs[Keys.PORT] = settings.port
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The high-level, coroutine-driven MPD client. It owns **two** connections, the
|
||||
* pattern MALP and other real clients use:
|
||||
*
|
||||
* - a **command** connection, guarded by a [Mutex], for request/response
|
||||
* commands (play, setvol, playlistinfo, …);
|
||||
* - an **idle** connection parked in the blocking `idle` command, so the server
|
||||
* can push change notifications. Each `changed:` event triggers a refresh on
|
||||
* the command connection, which flows out through [status]/[currentSong].
|
||||
*
|
||||
* Why two connections: `idle` blocks its connection indefinitely, so it can't
|
||||
* also carry commands. Splitting them lets the UI stay live (idle) while still
|
||||
* issuing actions (command) without racing on one socket.
|
||||
*
|
||||
* All blocking socket I/O is dispatched to [ioDispatcher]. Observe the exposed
|
||||
* [StateFlow]s from the UI; call [connect]/[disconnect] to manage the link.
|
||||
*
|
||||
* A [connectionFactory] is injectable so tests can supply fake transports; it
|
||||
* defaults to a real TCP [MpdConnection.connect].
|
||||
*/
|
||||
class MpdClient(
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val connectionFactory: (host: String, port: Int, readTimeoutMs: Int) -> MpdConnection =
|
||||
{ host, port, readTimeoutMs -> MpdConnection.connect(host, port, readTimeoutMs = readTimeoutMs) },
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
|
||||
|
||||
private val commandMutex = Mutex()
|
||||
private var commandConn: MpdConnection? = null
|
||||
private var idleConn: MpdConnection? = null
|
||||
private var idleJob: Job? = null
|
||||
|
||||
/**
|
||||
* True while a [disconnect] is in progress, so the idle loop can tell an
|
||||
* intentional socket close apart from a real connection drop.
|
||||
*/
|
||||
@Volatile
|
||||
private var shuttingDown = false
|
||||
|
||||
private val _connectionState = MutableStateFlow<MpdConnectionState>(MpdConnectionState.Disconnected)
|
||||
val connectionState = _connectionState.asStateFlow()
|
||||
|
||||
private val _status = MutableStateFlow<MpdStatus?>(null)
|
||||
val status = _status.asStateFlow()
|
||||
|
||||
private val _currentSong = MutableStateFlow<MpdSong?>(null)
|
||||
val currentSong = _currentSong.asStateFlow()
|
||||
|
||||
// --- Lifecycle ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open both connections, authenticate, prime the initial state, and start
|
||||
* the idle loop. Safe to await; failures land in [connectionState] as
|
||||
* [MpdConnectionState.Error] and the call throws.
|
||||
*/
|
||||
suspend fun connect(host: String, port: Int = MpdConnection.DEFAULT_PORT, password: String? = null) {
|
||||
disconnect() // ensure a clean slate if re-connecting
|
||||
shuttingDown = false
|
||||
_connectionState.value = MpdConnectionState.Connecting
|
||||
try {
|
||||
withContext(ioDispatcher) {
|
||||
// Command connection: a finite read timeout so a wedged server
|
||||
// surfaces as an error instead of hanging a UI action forever.
|
||||
val command = connectionFactory(host, port, COMMAND_READ_TIMEOUT_MS)
|
||||
// Idle connection: no read timeout — it must park indefinitely.
|
||||
val idle = connectionFactory(host, port, 0)
|
||||
if (password != null) {
|
||||
command.execute(MpdCommands.password(password))
|
||||
idle.execute(MpdCommands.password(password))
|
||||
}
|
||||
commandConn = command
|
||||
idleConn = idle
|
||||
}
|
||||
refresh()
|
||||
_connectionState.value = MpdConnectionState.Connected
|
||||
startIdleLoop()
|
||||
} catch (e: IOException) {
|
||||
failAndClose(e.message ?: "connection failed")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down the idle loop and both connections; returns to [MpdConnectionState.Disconnected]. */
|
||||
suspend fun disconnect() {
|
||||
shuttingDown = true
|
||||
// Closing the idle socket unblocks the loop's parked `idle` read.
|
||||
withContext(ioDispatcher) {
|
||||
idleConn?.close()
|
||||
commandConn?.close()
|
||||
}
|
||||
idleJob?.cancelAndJoin()
|
||||
idleJob = null
|
||||
idleConn = null
|
||||
commandConn = null
|
||||
if (_connectionState.value !is MpdConnectionState.Error) {
|
||||
_connectionState.value = MpdConnectionState.Disconnected
|
||||
}
|
||||
shuttingDown = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-suspending teardown for owner destruction (e.g. `ViewModel.onCleared`).
|
||||
* Cancels the internal scope and drops the sockets without waiting.
|
||||
*/
|
||||
fun shutdown() {
|
||||
shuttingDown = true
|
||||
try {
|
||||
idleConn?.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
try {
|
||||
commandConn?.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
idleConn = null
|
||||
commandConn = null
|
||||
scope.cancel()
|
||||
_connectionState.value = MpdConnectionState.Disconnected
|
||||
}
|
||||
|
||||
// --- Commands -----------------------------------------------------------
|
||||
//
|
||||
// These don't optimistically update the flows: the change they cause makes
|
||||
// the server emit an idle event, which refreshes state through the idle
|
||||
// loop. That keeps the app in lockstep with the server (and with other
|
||||
// clients) rather than guessing.
|
||||
|
||||
suspend fun play() = run(MpdCommands.play())
|
||||
suspend fun playPos(pos: Int) = run(MpdCommands.playPos(pos))
|
||||
suspend fun playId(songId: Int) = run(MpdCommands.playId(songId))
|
||||
suspend fun stop() = run(MpdCommands.stop())
|
||||
suspend fun next() = run(MpdCommands.next())
|
||||
suspend fun previous() = run(MpdCommands.previous())
|
||||
suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused))
|
||||
|
||||
/** Flip play/pause based on the latest known [status]. No-op if state is unknown. */
|
||||
suspend fun togglePause() {
|
||||
val playing = _status.value?.state == ca.ksamad.musicremote.mpd.model.PlayerState.PLAY
|
||||
run(MpdCommands.pause(playing))
|
||||
}
|
||||
|
||||
suspend fun seekCurrent(seconds: Double) = run(MpdCommands.seekCurrent(seconds))
|
||||
suspend fun setVolume(volume: Int) = run(MpdCommands.setVolume(volume))
|
||||
suspend fun setRepeat(on: Boolean) = run(MpdCommands.repeat(on))
|
||||
suspend fun setRandom(on: Boolean) = run(MpdCommands.random(on))
|
||||
suspend fun setSingle(on: Boolean) = run(MpdCommands.single(on))
|
||||
suspend fun setConsume(on: Boolean) = run(MpdCommands.consume(on))
|
||||
|
||||
/** Fetch the current play queue. */
|
||||
suspend fun queue(): List<MpdSong> = withCommand { conn ->
|
||||
conn.execute(MpdCommands.playlistInfo()).split().mapNotNull { MpdSong.from(it) }
|
||||
}
|
||||
|
||||
// --- Internals ----------------------------------------------------------
|
||||
|
||||
/** Run a fire-and-forget command whose response we don't need. */
|
||||
private suspend fun run(commandLine: String) {
|
||||
withCommand { it.execute(commandLine) }
|
||||
}
|
||||
|
||||
/** Serialize access to the command connection and run [block] on the I/O dispatcher. */
|
||||
private suspend fun <T> withCommand(block: (MpdConnection) -> T): T =
|
||||
withContext(ioDispatcher) {
|
||||
commandMutex.withLock {
|
||||
val conn = commandConn
|
||||
?: throw MpdConnectionException("not connected")
|
||||
block(conn)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read `status` and `currentsong` into the flows. */
|
||||
private suspend fun refresh() {
|
||||
val (status, song) = withCommand { conn ->
|
||||
val status = MpdStatus.from(conn.execute(MpdCommands.status()).toMap())
|
||||
val song = MpdSong.from(conn.execute(MpdCommands.currentSong()).toMap())
|
||||
status to song
|
||||
}
|
||||
_status.value = status
|
||||
_currentSong.value = song
|
||||
}
|
||||
|
||||
private fun startIdleLoop() {
|
||||
val idle = idleConn ?: return
|
||||
idleJob = scope.launch {
|
||||
try {
|
||||
while (isActive) {
|
||||
// Blocks here until the server reports a change (or we close
|
||||
// the socket during disconnect, which throws below).
|
||||
val changed = withContext(ioDispatcher) {
|
||||
idle.execute(MpdCommands.idle()).getAll("changed")
|
||||
}
|
||||
if (changed.isEmpty()) continue
|
||||
// Any of these subsystems affect what we currently show.
|
||||
if (changed.any { it in REFRESHING_SUBSYSTEMS }) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (!shuttingDown) failAndClose(e.message ?: "idle connection lost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Record an error state and drop the connections (best-effort). */
|
||||
private fun failAndClose(message: String) {
|
||||
_connectionState.value = MpdConnectionState.Error(message)
|
||||
try {
|
||||
idleConn?.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
try {
|
||||
commandConn?.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
idleConn = null
|
||||
commandConn = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val COMMAND_READ_TIMEOUT_MS = 10_000
|
||||
|
||||
/** Idle subsystems that change something [status]/[currentSong] reflects. */
|
||||
private val REFRESHING_SUBSYSTEMS = setOf("player", "mixer", "options", "playlist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
/**
|
||||
* Typed builders for the command lines we send. These return the assembled,
|
||||
* properly-quoted string (no newline) that gets handed to
|
||||
* [MpdConnection.execute]; keeping them pure makes both the argument quoting and
|
||||
* the exact wire form unit-testable without any I/O.
|
||||
*
|
||||
* This is deliberately a small, growing subset — the transport controls, option
|
||||
* toggles, and status queries a remote UI needs first. Database/queue/playlist
|
||||
* builders get added as those features land.
|
||||
*/
|
||||
object MpdCommands {
|
||||
|
||||
// --- Status queries -----------------------------------------------------
|
||||
|
||||
fun status() = MpdProtocol.command("status")
|
||||
fun currentSong() = MpdProtocol.command("currentsong")
|
||||
fun stats() = MpdProtocol.command("stats")
|
||||
|
||||
/**
|
||||
* Block until one of [subsystems] changes (all of them if none given). Only
|
||||
* legal on a connection dedicated to idling — never inside a command list.
|
||||
*/
|
||||
fun idle(vararg subsystems: String) =
|
||||
if (subsystems.isEmpty()) MpdProtocol.command("idle")
|
||||
else MpdProtocol.command("idle", *subsystems)
|
||||
|
||||
fun noidle() = MpdProtocol.command("noidle")
|
||||
fun ping() = MpdProtocol.command("ping")
|
||||
|
||||
// --- Authentication -----------------------------------------------------
|
||||
|
||||
fun password(password: String) = MpdProtocol.command("password", password)
|
||||
|
||||
// --- Transport ----------------------------------------------------------
|
||||
|
||||
fun play() = MpdProtocol.command("play")
|
||||
fun playPos(pos: Int) = MpdProtocol.command("play", pos.toString())
|
||||
fun playId(songId: Int) = MpdProtocol.command("playid", songId.toString())
|
||||
fun stop() = MpdProtocol.command("stop")
|
||||
fun next() = MpdProtocol.command("next")
|
||||
fun previous() = MpdProtocol.command("previous")
|
||||
|
||||
/** `pause 1`/`pause 0`; with no state MPD toggles, but we send it explicitly. */
|
||||
fun pause(paused: Boolean) =
|
||||
MpdProtocol.command("pause", if (paused) "1" else "0")
|
||||
|
||||
/** Seek to [seconds] within the currently playing song. */
|
||||
fun seekCurrent(seconds: Double) =
|
||||
MpdProtocol.command("seekcur", seconds.toString())
|
||||
|
||||
// --- Options / mixer ----------------------------------------------------
|
||||
|
||||
fun setVolume(volume: Int) =
|
||||
MpdProtocol.command("setvol", volume.coerceIn(0, 100).toString())
|
||||
|
||||
fun repeat(on: Boolean) = MpdProtocol.command("repeat", if (on) "1" else "0")
|
||||
fun random(on: Boolean) = MpdProtocol.command("random", if (on) "1" else "0")
|
||||
fun single(on: Boolean) = MpdProtocol.command("single", if (on) "1" else "0")
|
||||
fun consume(on: Boolean) = MpdProtocol.command("consume", if (on) "1" else "0")
|
||||
|
||||
// --- Queue --------------------------------------------------------------
|
||||
|
||||
fun playlistInfo() = MpdProtocol.command("playlistinfo")
|
||||
fun clear() = MpdProtocol.command("clear")
|
||||
fun add(uri: String) = MpdProtocol.command("add", uri)
|
||||
fun deleteId(songId: Int) = MpdProtocol.command("deleteid", songId.toString())
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
|
||||
/**
|
||||
* A single synchronous MPD connection: the raw text protocol over a byte
|
||||
* stream. One command is written, one response is read back; calls are **not**
|
||||
* thread-safe and must be serialized by the caller (a higher-level client will
|
||||
* own a [MpdConnection] behind a mutex, and a second one parked in `idle`).
|
||||
*
|
||||
* The transport is an [InputStream]/[OutputStream] pair rather than a [Socket]
|
||||
* so the protocol can be exercised against in-memory streams in tests. Use
|
||||
* [connect] for a real TCP connection.
|
||||
*
|
||||
* Reading is done at the byte level (not via a [java.io.Reader]) because
|
||||
* responses can interleave UTF-8 text lines with raw binary payloads, and a
|
||||
* buffered character reader would happily swallow bytes past a line boundary.
|
||||
*/
|
||||
class MpdConnection(
|
||||
private val input: InputStream,
|
||||
private val output: OutputStream,
|
||||
private val closer: Closeable? = null,
|
||||
) : Closeable {
|
||||
|
||||
/** Protocol version from the greeting, e.g. `0.23.5`. Null until [handshake]. */
|
||||
var protocolVersion: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Read and validate the server greeting (`OK MPD <version>`). Must be called
|
||||
* exactly once, before any command. Returns the protocol version.
|
||||
*/
|
||||
fun handshake(): String {
|
||||
val line = readLine()
|
||||
?: throw MpdConnectionException("connection closed before greeting")
|
||||
if (!line.startsWith(MpdProtocol.GREETING_PREFIX)) {
|
||||
throw MpdConnectionException("unexpected greeting: $line")
|
||||
}
|
||||
val version = line.removePrefix(MpdProtocol.GREETING_PREFIX).trim()
|
||||
protocolVersion = version
|
||||
return version
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one command line (name + already-assembled string, no newline) and
|
||||
* read its response up to the terminating `OK`.
|
||||
*
|
||||
* @throws MpdAckException if the server replied `ACK …`.
|
||||
* @throws MpdConnectionException on EOF or a malformed response.
|
||||
*/
|
||||
fun execute(commandLine: String): MpdResponse {
|
||||
write(commandLine)
|
||||
return readResponse()
|
||||
}
|
||||
|
||||
/** Convenience: [MpdProtocol.command] + [execute]. */
|
||||
fun execute(name: String, vararg args: String): MpdResponse =
|
||||
execute(MpdProtocol.command(name, *args))
|
||||
|
||||
private fun write(commandLine: String) {
|
||||
output.write(commandLine.toByteArray(Charsets.UTF_8))
|
||||
output.write('\n'.code)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun readResponse(): MpdResponse {
|
||||
val values = ArrayList<Pair<String, String>>()
|
||||
var binary: ByteArray? = null
|
||||
while (true) {
|
||||
val line = readLine()
|
||||
?: throw MpdConnectionException("connection closed mid-response")
|
||||
when {
|
||||
line == MpdProtocol.OK -> return MpdResponse(values, binary)
|
||||
line.startsWith("ACK ") ->
|
||||
throw MpdAckException.parse(line)
|
||||
?: MpdConnectionException("malformed ACK: $line")
|
||||
else -> {
|
||||
val sep = line.indexOf(": ")
|
||||
if (sep < 0) continue // tolerate stray lines rather than fail
|
||||
val key = line.substring(0, sep)
|
||||
val value = line.substring(sep + 2)
|
||||
if (key == "binary") {
|
||||
binary = readBinary(
|
||||
value.toIntOrNull()
|
||||
?: throw MpdConnectionException("bad binary size: $value")
|
||||
)
|
||||
} else {
|
||||
values.add(key to value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read raw bytes until `\n` (exclusive), decode UTF-8. Null on immediate EOF. */
|
||||
private fun readLine(): String? {
|
||||
val buf = ByteArrayOutputStream(64)
|
||||
while (true) {
|
||||
val b = input.read()
|
||||
when (b) {
|
||||
-1 -> return if (buf.size() == 0) null else buf.toString("UTF-8")
|
||||
'\n'.code -> return buf.toString("UTF-8")
|
||||
else -> buf.write(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read exactly [size] bytes of a binary payload, then consume the trailing `\n`. */
|
||||
private fun readBinary(size: Int): ByteArray {
|
||||
val data = ByteArray(size)
|
||||
var off = 0
|
||||
while (off < size) {
|
||||
val n = input.read(data, off, size - off)
|
||||
if (n == -1) throw MpdConnectionException("EOF after ${off}/$size binary bytes")
|
||||
off += n
|
||||
}
|
||||
input.read() // trailing newline that follows the binary block
|
||||
return data
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// Best-effort: closing the socket tears down both streams.
|
||||
try {
|
||||
closer?.close() ?: output.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PORT = 6600
|
||||
|
||||
/**
|
||||
* Open a TCP connection to an MPD server and complete the greeting
|
||||
* handshake. Blocking — call off the main thread.
|
||||
*
|
||||
* @param connectTimeoutMs socket connect timeout.
|
||||
* @param readTimeoutMs `SO_TIMEOUT`; 0 means block forever (needed for
|
||||
* the `idle` connection, which parks indefinitely).
|
||||
*/
|
||||
fun connect(
|
||||
host: String,
|
||||
port: Int = DEFAULT_PORT,
|
||||
connectTimeoutMs: Int = 5_000,
|
||||
readTimeoutMs: Int = 0,
|
||||
): MpdConnection {
|
||||
val socket = Socket()
|
||||
try {
|
||||
socket.connect(InetSocketAddress(host, port), connectTimeoutMs)
|
||||
socket.soTimeout = readTimeoutMs
|
||||
socket.tcpNoDelay = true
|
||||
val conn = MpdConnection(
|
||||
input = BufferedInputStream(socket.getInputStream()),
|
||||
output = BufferedOutputStream(socket.getOutputStream()),
|
||||
closer = socket,
|
||||
)
|
||||
conn.handshake()
|
||||
return conn
|
||||
} catch (e: IOException) {
|
||||
socket.close()
|
||||
throw MpdConnectionException("failed to connect to $host:$port", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
/** Lifecycle of an [MpdClient]'s link to a server, surfaced as observable state. */
|
||||
sealed interface MpdConnectionState {
|
||||
/** No connection; the initial and post-[MpdClient.disconnect] state. */
|
||||
data object Disconnected : MpdConnectionState
|
||||
|
||||
/** A connection attempt is in flight. */
|
||||
data object Connecting : MpdConnectionState
|
||||
|
||||
/** Both the command and idle connections are up and the idle loop is running. */
|
||||
data object Connected : MpdConnectionState
|
||||
|
||||
/** The connection dropped or failed; [message] describes why. */
|
||||
data class Error(val message: String) : MpdConnectionState
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
/** Base type for every failure the MPD layer can raise. */
|
||||
sealed class MpdException(message: String, cause: Throwable? = null) :
|
||||
IOException(message, cause)
|
||||
|
||||
/**
|
||||
* A transport-level problem: the socket closed, the greeting was malformed, an
|
||||
* EOF arrived mid-response, etc. These are the failures that mean "the
|
||||
* connection can no longer be trusted", as opposed to a command the server
|
||||
* simply rejected.
|
||||
*/
|
||||
class MpdConnectionException(message: String, cause: Throwable? = null) :
|
||||
MpdException(message, cause)
|
||||
|
||||
/**
|
||||
* The server understood us but refused the command, i.e. it replied with an
|
||||
* `ACK` line. The wire format is:
|
||||
*
|
||||
* ```
|
||||
* ACK [error@command_listNum] {current_command} message_text
|
||||
* ```
|
||||
*
|
||||
* where `error` is one of MPD's numeric `ACK_ERROR_*` codes (see [Code]),
|
||||
* `command_listNum` is the 0-based offset of the failing command inside a
|
||||
* command list (0 for a bare command), and `current_command` names it.
|
||||
*/
|
||||
class MpdAckException(
|
||||
val code: Int,
|
||||
val commandListNum: Int,
|
||||
val command: String,
|
||||
val serverMessage: String,
|
||||
) : MpdException("ACK [$code@$commandListNum] {$command} $serverMessage") {
|
||||
|
||||
/** MPD's `ACK_ERROR_*` constants, for callers that want to branch on them. */
|
||||
object Code {
|
||||
const val NOT_LIST = 1
|
||||
const val ARG = 2
|
||||
const val PASSWORD = 3
|
||||
const val PERMISSION = 4
|
||||
const val UNKNOWN = 5
|
||||
const val NO_EXIST = 50
|
||||
const val PLAYLIST_MAX = 51
|
||||
const val SYSTEM = 52
|
||||
const val PLAYLIST_LOAD = 53
|
||||
const val UPDATE_ALREADY = 54
|
||||
const val PLAYER_SYNC = 55
|
||||
const val EXIST = 56
|
||||
}
|
||||
|
||||
companion object {
|
||||
// ACK [2@1] {play} Bad song index
|
||||
private val PATTERN = Regex("""ACK \[(\d+)@(\d+)] \{([^}]*)} ?(.*)""")
|
||||
|
||||
/**
|
||||
* Parse a raw `ACK …` response line. Returns `null` if [line] is not a
|
||||
* well-formed ACK, so the caller can decide how to treat garbage.
|
||||
*/
|
||||
fun parse(line: String): MpdAckException? {
|
||||
val m = PATTERN.matchEntire(line) ?: return null
|
||||
val (code, listNum, command, message) = m.destructured
|
||||
return MpdAckException(
|
||||
code = code.toInt(),
|
||||
commandListNum = listNum.toInt(),
|
||||
command = command,
|
||||
serverMessage = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
/**
|
||||
* Pure, connection-independent helpers for speaking the MPD text protocol:
|
||||
* argument quoting and command-string assembly. Kept separate from
|
||||
* [MpdConnection] so it can be unit-tested without any I/O.
|
||||
*/
|
||||
object MpdProtocol {
|
||||
|
||||
/** Every server greeting starts with this, followed by the protocol version. */
|
||||
const val GREETING_PREFIX = "OK MPD "
|
||||
|
||||
/** Success terminator for a command's response. */
|
||||
const val OK = "OK"
|
||||
|
||||
/** Per-command terminator inside a `command_list_ok_begin` list. */
|
||||
const val LIST_OK = "list_OK"
|
||||
|
||||
/**
|
||||
* Quote a single command argument. MPD's tokenizer treats a bare token as
|
||||
* ending at the next whitespace, so any argument is wrapped in double quotes
|
||||
* with embedded `"` and `\` backslash-escaped. Always quoting (even numbers
|
||||
* and empty strings) is accepted by the server and keeps callers from having
|
||||
* to reason about which values are "safe".
|
||||
*/
|
||||
fun quote(arg: String): String {
|
||||
val sb = StringBuilder(arg.length + 2)
|
||||
sb.append('"')
|
||||
for (c in arg) {
|
||||
if (c == '"' || c == '\\') sb.append('\\')
|
||||
sb.append(c)
|
||||
}
|
||||
sb.append('"')
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a full command line (without the trailing newline) from a command
|
||||
* name and its arguments. The name is emitted verbatim; every argument is
|
||||
* [quote]d.
|
||||
*/
|
||||
fun command(name: String, vararg args: String): String {
|
||||
if (args.isEmpty()) return name
|
||||
return buildString {
|
||||
append(name)
|
||||
for (a in args) {
|
||||
append(' ')
|
||||
append(quote(a))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
/**
|
||||
* The parsed result of a single successful command: the ordered `key: value`
|
||||
* lines the server sent before its `OK`, plus an optional [binary] payload for
|
||||
* commands like `albumart`/`readpicture`.
|
||||
*
|
||||
* Order is preserved because several commands (e.g. `playlistinfo`, `lsinfo`)
|
||||
* return repeated blocks that are only separable by position — a new object
|
||||
* begins each time a delimiting key such as `file` reappears. Use [split] to
|
||||
* chop such a response into per-object maps.
|
||||
*/
|
||||
class MpdResponse(
|
||||
val values: List<Pair<String, String>>,
|
||||
val binary: ByteArray? = null,
|
||||
) {
|
||||
/** First value for [key], or `null` if absent. */
|
||||
operator fun get(key: String): String? =
|
||||
values.firstOrNull { it.first == key }?.second
|
||||
|
||||
/** All values for [key], in order. */
|
||||
fun getAll(key: String): List<String> =
|
||||
values.filter { it.first == key }.map { it.second }
|
||||
|
||||
/**
|
||||
* Flatten to a map of first-seen values. Safe for commands whose keys are
|
||||
* unique (`status`, `currentsong`, `stats`); lossy for repeated blocks —
|
||||
* use [split] there instead.
|
||||
*/
|
||||
fun toMap(): Map<String, String> {
|
||||
val out = LinkedHashMap<String, String>(values.size)
|
||||
for ((k, v) in values) out.putIfAbsent(k, v)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a multi-object response into one map per object. A new object starts
|
||||
* at every occurrence of [delimiter] (default `file`, the first key MPD emits
|
||||
* for a song/file entry). Lines before the first delimiter are ignored.
|
||||
*/
|
||||
fun split(delimiter: String = "file"): List<Map<String, String>> {
|
||||
val result = ArrayList<Map<String, String>>()
|
||||
var current: LinkedHashMap<String, String>? = null
|
||||
for ((k, v) in values) {
|
||||
if (k == delimiter) {
|
||||
current = LinkedHashMap()
|
||||
result.add(current)
|
||||
}
|
||||
current?.putIfAbsent(k, v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ca.ksamad.musicremote.mpd.model
|
||||
|
||||
/**
|
||||
* A song/file entry, parsed from the metadata block MPD emits for `currentsong`,
|
||||
* `playlistinfo`, `find`, `lsinfo`, etc. [uri] (the `file` key) is the only
|
||||
* required field; every tag is optional because the server only sends tags the
|
||||
* file actually has.
|
||||
*
|
||||
* Tags are kept as raw strings — `track`/`disc` can carry values like `"3/12"`,
|
||||
* and normalising them is a display concern left to the UI layer.
|
||||
*/
|
||||
data class MpdSong(
|
||||
val uri: String,
|
||||
val title: String?,
|
||||
val artist: String?,
|
||||
val album: String?,
|
||||
val albumArtist: String?,
|
||||
val track: String?,
|
||||
val disc: String?,
|
||||
val date: String?,
|
||||
val genre: String?,
|
||||
val duration: Double?, // from "duration" (fractional) or legacy "Time"
|
||||
val pos: Int?, // queue position, present in queue listings
|
||||
val id: Int?, // stable queue id, present in queue listings
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Parse one song from a metadata map. Returns `null` when there is no
|
||||
* `file` key (i.e. this block is not a song — e.g. a `directory` entry
|
||||
* in an `lsinfo` listing).
|
||||
*/
|
||||
fun from(values: Map<String, String>): MpdSong? {
|
||||
val uri = values["file"] ?: return null
|
||||
return MpdSong(
|
||||
uri = uri,
|
||||
title = values["Title"],
|
||||
artist = values["Artist"],
|
||||
album = values["Album"],
|
||||
albumArtist = values["AlbumArtist"],
|
||||
track = values["Track"],
|
||||
disc = values["Disc"],
|
||||
date = values["Date"],
|
||||
genre = values["Genre"],
|
||||
duration = values["duration"]?.toDoubleOrNull()
|
||||
?: values["Time"]?.toDoubleOrNull(),
|
||||
pos = values["Pos"]?.toIntOrNull(),
|
||||
id = values["Id"]?.toIntOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ca.ksamad.musicremote.mpd.model
|
||||
|
||||
/**
|
||||
* Database/server statistics from a `stats` response. Durations are in seconds
|
||||
* and the update timestamp is Unix epoch seconds, both as MPD reports them.
|
||||
*/
|
||||
data class MpdStatistics(
|
||||
val artists: Int,
|
||||
val albums: Int,
|
||||
val songs: Int,
|
||||
val uptimeSeconds: Long,
|
||||
val playtimeSeconds: Long,
|
||||
val dbPlaytimeSeconds: Long,
|
||||
val dbUpdateEpochSeconds: Long,
|
||||
) {
|
||||
companion object {
|
||||
fun from(values: Map<String, String>): MpdStatistics = MpdStatistics(
|
||||
artists = values["artists"]?.toIntOrNull() ?: 0,
|
||||
albums = values["albums"]?.toIntOrNull() ?: 0,
|
||||
songs = values["songs"]?.toIntOrNull() ?: 0,
|
||||
uptimeSeconds = values["uptime"]?.toLongOrNull() ?: 0,
|
||||
playtimeSeconds = values["playtime"]?.toLongOrNull() ?: 0,
|
||||
dbPlaytimeSeconds = values["db_playtime"]?.toLongOrNull() ?: 0,
|
||||
dbUpdateEpochSeconds = values["db_update"]?.toLongOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package ca.ksamad.musicremote.mpd.model
|
||||
|
||||
/** Player transport state as reported by the `state` field of `status`. */
|
||||
enum class PlayerState {
|
||||
PLAY, PAUSE, STOP, UNKNOWN;
|
||||
|
||||
companion object {
|
||||
fun parse(raw: String?): PlayerState = when (raw) {
|
||||
"play" -> PLAY
|
||||
"pause" -> PAUSE
|
||||
"stop" -> STOP
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot of the player, parsed from a `status` response. Fields absent from
|
||||
* the response (e.g. `song`/`elapsed` while stopped) are `null`.
|
||||
*
|
||||
* See the MPD protocol docs for field semantics. Only the fields a remote UI
|
||||
* actually drives are surfaced here; more can be added as needed.
|
||||
*/
|
||||
data class MpdStatus(
|
||||
val volume: Int?, // 0..100; null when MPD reports -1 (output closed / volume unavailable)
|
||||
val repeat: Boolean,
|
||||
val random: Boolean,
|
||||
val single: Boolean, // true for "1" or "oneshot"
|
||||
val consume: Boolean,
|
||||
val playlistVersion: Int?, // queue version; bumps on every queue change
|
||||
val playlistLength: Int, // number of songs in the queue
|
||||
val state: PlayerState,
|
||||
val song: Int?, // queue position of the current song
|
||||
val songId: Int?, // stable id of the current song
|
||||
val nextSong: Int?,
|
||||
val nextSongId: Int?,
|
||||
val elapsed: Double?, // seconds into the current song (fractional)
|
||||
val duration: Double?, // length of the current song in seconds
|
||||
val bitrate: Int?, // instantaneous kbps
|
||||
val audio: String?, // e.g. "44100:16:2"
|
||||
val error: String?, // last player error, if any
|
||||
) {
|
||||
companion object {
|
||||
fun from(values: Map<String, String>): MpdStatus = MpdStatus(
|
||||
volume = values["volume"]?.toIntOrNull()?.takeIf { it >= 0 },
|
||||
repeat = values["repeat"] == "1",
|
||||
random = values["random"] == "1",
|
||||
single = values["single"].let { it == "1" || it == "oneshot" },
|
||||
consume = values["consume"] == "1",
|
||||
playlistVersion = values["playlist"]?.toIntOrNull(),
|
||||
playlistLength = values["playlistlength"]?.toIntOrNull() ?: 0,
|
||||
state = PlayerState.parse(values["state"]),
|
||||
song = values["song"]?.toIntOrNull(),
|
||||
songId = values["songid"]?.toIntOrNull(),
|
||||
nextSong = values["nextsong"]?.toIntOrNull(),
|
||||
nextSongId = values["nextsongid"]?.toIntOrNull(),
|
||||
elapsed = values["elapsed"]?.toDoubleOrNull(),
|
||||
duration = values["duration"]?.toDoubleOrNull(),
|
||||
bitrate = values["bitrate"]?.toIntOrNull(),
|
||||
audio = values["audio"],
|
||||
error = values["error"],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
||||
|
||||
/**
|
||||
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
|
||||
* brief loading splash while it reads settings / auto-connects, the connect form
|
||||
* (first run, after disconnect, or on error), or the now-playing screen.
|
||||
*/
|
||||
@Composable
|
||||
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
|
||||
val screen by vm.screen.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = screen) {
|
||||
is AppScreen.Loading -> LoadingScreen()
|
||||
is AppScreen.Connect -> ConnectScreen(vm, s.settings, s.error)
|
||||
is AppScreen.Player -> NowPlayingScreen(vm)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingScreen() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
"Connecting…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectScreen(vm: PlayerViewModel, saved: ConnectionSettings, error: String?) {
|
||||
// Seed the fields from the persisted settings. Keyed on the loaded values so
|
||||
// the form re-seeds once DataStore delivers them, but a user's edits after
|
||||
// that stick (rememberSaveable also survives rotation/process death).
|
||||
var host by rememberSaveable(saved.host) { mutableStateOf(saved.host) }
|
||||
var port by rememberSaveable(saved.port) { mutableStateOf(saved.port.toString()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Music Remote", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"Connect to your MPD server",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 24.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = port,
|
||||
onValueChange = { port = it.filter(Char::isDigit) },
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = { vm.connect(host, port.toIntOrNull() ?: 6600) },
|
||||
enabled = host.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Connect")
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* 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 the server pushes a change —
|
||||
* including changes made from other clients.
|
||||
*/
|
||||
@Composable
|
||||
fun NowPlayingScreen(vm: PlayerViewModel) {
|
||||
val status by vm.status.collectAsStateWithLifecycle()
|
||||
val song by vm.currentSong.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// --- Track metadata --------------------------------------------------
|
||||
Text(
|
||||
text = song?.title ?: song?.uri ?: "Nothing playing",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = song?.artist ?: "—",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = song?.album ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
SeekBar(status = status, onSeek = { vm.seekTo(it) })
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// --- Transport -------------------------------------------------------
|
||||
val playing = status?.state == PlayerState.PLAY
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) {
|
||||
GlyphText("⏮", 28.sp)
|
||||
}
|
||||
FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) {
|
||||
GlyphText(if (playing) "⏸" else "▶", 32.sp)
|
||||
}
|
||||
IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) {
|
||||
GlyphText("⏭", 28.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
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") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = status?.random == true,
|
||||
onClick = { vm.setRandom(status?.random != true) },
|
||||
label = { Text("Shuffle") },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
TextButton(onClick = vm::disconnect) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek bar that ticks locally between server updates. MPD only pushes a change
|
||||
* event on discrete events (play/pause/seek/song change), not once per second,
|
||||
* so we advance [MpdStatus.elapsed] locally while playing to keep the bar
|
||||
* moving, resyncing whenever a fresh status arrives.
|
||||
*/
|
||||
@Composable
|
||||
private fun SeekBar(status: MpdStatus?, onSeek: (Double) -> Unit) {
|
||||
val duration = status?.duration ?: 0.0
|
||||
val playing = status?.state == PlayerState.PLAY
|
||||
|
||||
// Local playback position, reseeded on every new status snapshot.
|
||||
var position by remember(status?.songId, status?.elapsed, status?.state) {
|
||||
mutableFloatStateOf((status?.elapsed ?: 0.0).toFloat())
|
||||
}
|
||||
var dragValue by remember { mutableStateOf<Float?>(null) }
|
||||
|
||||
// Advance ~4x/sec while playing and not being dragged.
|
||||
LaunchedEffect(status?.songId, status?.elapsed, status?.state) {
|
||||
if (!playing) return@LaunchedEffect
|
||||
while (true) {
|
||||
delay(250)
|
||||
if (dragValue == null && position < duration) position += 0.25f
|
||||
}
|
||||
}
|
||||
|
||||
val shown = dragValue ?: position
|
||||
Slider(
|
||||
value = shown.coerceIn(0f, duration.toFloat().coerceAtLeast(0f)),
|
||||
onValueChange = { dragValue = it },
|
||||
onValueChangeFinished = {
|
||||
dragValue?.let { onSeek(it.toDouble()) }
|
||||
dragValue = null
|
||||
},
|
||||
valueRange = 0f..duration.toFloat().coerceAtLeast(1f),
|
||||
enabled = status != null && duration > 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatTime(shown.toDouble()), style = MaterialTheme.typography.labelMedium)
|
||||
Text(formatTime(duration), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
|
||||
// Local thumb position seeded from the server; committed on release.
|
||||
var dragValue by remember(volume) { mutableStateOf<Float?>(null) }
|
||||
val enabled = volume != null
|
||||
val shown = dragValue ?: (volume ?: 0).toFloat()
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
GlyphText("🔈", 18.sp)
|
||||
Slider(
|
||||
value = shown,
|
||||
onValueChange = { dragValue = it },
|
||||
onValueChangeFinished = {
|
||||
dragValue?.let { onSetVolume(it.toInt()) }
|
||||
dragValue = null
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
)
|
||||
Text(
|
||||
text = if (enabled) "${shown.toInt()}" else "—",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.size(width = 32.dp, height = 20.dp),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A centered glyph, used in place of vector icons to avoid an extra dependency. */
|
||||
@Composable
|
||||
private fun GlyphText(glyph: String, size: androidx.compose.ui.unit.TextUnit) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(glyph, fontSize = size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(seconds: Double): String {
|
||||
val total = seconds.toInt().coerceAtLeast(0)
|
||||
val m = total / 60
|
||||
val s = total % 60
|
||||
return "%d:%02d".format(m, s)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
||||
import ca.ksamad.musicremote.data.SettingsRepository
|
||||
import ca.ksamad.musicremote.mpd.MpdClient
|
||||
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Which top-level screen to show. Derived from connection + persisted settings. */
|
||||
sealed interface AppScreen {
|
||||
/** Reading settings / auto-connecting — a brief splash, never the connect form. */
|
||||
data object Loading : AppScreen
|
||||
|
||||
/** First run, after a disconnect, or a failed connection. */
|
||||
data class Connect(val settings: ConnectionSettings, val error: String?) : AppScreen
|
||||
|
||||
/** Connected: show playback. */
|
||||
data object Player : AppScreen
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the single [MpdClient] for the app and exposes its state to Compose.
|
||||
*
|
||||
* On startup it reads the persisted [ConnectionSettings]; if a server was saved
|
||||
* it auto-connects straight into playback, so a returning user is never
|
||||
* prompted. The connect form only appears with no saved server, after an
|
||||
* explicit disconnect, or when a connection fails.
|
||||
*/
|
||||
class PlayerViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val client = MpdClient()
|
||||
private val settingsRepo = SettingsRepository(application)
|
||||
|
||||
val status = client.status
|
||||
val currentSong = client.currentSong
|
||||
|
||||
/** Persisted settings (defaulted) for seeding the connect form. */
|
||||
private val settings = settingsRepo.settings.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = ConnectionSettings.DEFAULT,
|
||||
)
|
||||
|
||||
// True until the startup auto-connect decision has been made, so we show a
|
||||
// splash instead of briefly flashing the connect form on cold start.
|
||||
private val bootstrapping = MutableStateFlow(true)
|
||||
|
||||
val screen: StateFlow<AppScreen> = combine(
|
||||
client.connectionState,
|
||||
bootstrapping,
|
||||
settings,
|
||||
) { connection, booting, saved ->
|
||||
when {
|
||||
booting -> AppScreen.Loading
|
||||
connection is MpdConnectionState.Connected -> AppScreen.Player
|
||||
connection is MpdConnectionState.Connecting -> AppScreen.Loading
|
||||
connection is MpdConnectionState.Error -> AppScreen.Connect(saved, connection.message)
|
||||
else -> AppScreen.Connect(saved, null) // Disconnected after startup
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = AppScreen.Loading,
|
||||
)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val saved = settingsRepo.settingsOrNull.first()
|
||||
if (saved != null) {
|
||||
connect(saved.host, saved.port)
|
||||
// Wait until the client actually leaves Disconnected before we
|
||||
// stop bootstrapping, so the form never flashes over the splash.
|
||||
client.connectionState.first { it !is MpdConnectionState.Disconnected }
|
||||
}
|
||||
bootstrapping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(host: String, port: Int) {
|
||||
val trimmed = host.trim()
|
||||
viewModelScope.launch {
|
||||
// Remember what we last connected to, so it's there next launch.
|
||||
settingsRepo.save(ConnectionSettings(trimmed, port))
|
||||
// connect() already routes failures into connectionState; swallow the
|
||||
// rethrow so a bad host doesn't crash the app.
|
||||
runCatching { client.connect(trimmed, port) }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
viewModelScope.launch { client.disconnect() }
|
||||
}
|
||||
|
||||
fun togglePlayPause() = fireAndForget { togglePause() }
|
||||
fun next() = fireAndForget { next() }
|
||||
fun previous() = fireAndForget { previous() }
|
||||
fun seekTo(seconds: Double) = fireAndForget { seekCurrent(seconds) }
|
||||
fun setVolume(volume: Int) = fireAndForget { setVolume(volume) }
|
||||
fun setRepeat(on: Boolean) = fireAndForget { setRepeat(on) }
|
||||
fun setRandom(on: Boolean) = fireAndForget { setRandom(on) }
|
||||
|
||||
private inline fun fireAndForget(crossinline action: suspend MpdClient.() -> Unit) {
|
||||
viewModelScope.launch { runCatching { client.action() } }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
client.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Music Remote</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<!--
|
||||
The app's UI is drawn entirely by Jetpack Compose (see Theme.kt), so this
|
||||
Android XML theme only needs to provide a bare, no-action-bar window.
|
||||
Using a platform parent avoids pulling in the extra Material Components
|
||||
XML library that we don't need for a Compose-only app.
|
||||
-->
|
||||
<style name="Theme.MusicRemote" parent="@android:style/Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
package ca.ksamad.musicremote
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A plain JVM unit test (runs on your machine, no device/emulator needed).
|
||||
* `gradle testDebugUnitTest` runs these — that's what the Nix build's check
|
||||
* phase invokes.
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Live end-to-end test of [MpdClient] against a real server (skipped unless
|
||||
* `MPD_HOST` is set — see [MpdServerIntegrationTest]).
|
||||
*
|
||||
* The interesting part is the idle round-trip: we change the volume on the
|
||||
* **command** connection and then wait for that change to arrive back through
|
||||
* the **idle** connection into the `status` flow — proving the two-connection
|
||||
* push architecture actually works. The volume nudge is reverted afterwards.
|
||||
*/
|
||||
class MpdClientIntegrationTest {
|
||||
|
||||
private val host = System.getenv("MPD_HOST")
|
||||
private val port = System.getenv("MPD_PORT")?.toIntOrNull() ?: MpdConnection.DEFAULT_PORT
|
||||
private val password = System.getenv("MPD_PASSWORD")
|
||||
|
||||
@Test
|
||||
fun clientConnectsAndReceivesIdlePushUpdates() = runBlocking {
|
||||
assumeTrue("set MPD_HOST to run the live MPD client test", host != null)
|
||||
val host = host!!
|
||||
|
||||
val client = MpdClient()
|
||||
try {
|
||||
println("=== MpdClient live test: $host:$port ===")
|
||||
client.connect(host, port, password)
|
||||
assertEquals(MpdConnectionState.Connected, client.connectionState.value)
|
||||
|
||||
// Initial state, primed during connect().
|
||||
val initial = withTimeout(5_000) { client.status.first { it != null } }!!
|
||||
println("connected. state=${initial.state} volume=${initial.volume} song='${client.currentSong.value?.title}'")
|
||||
|
||||
val startVolume = initial.volume
|
||||
if (startVolume == null) {
|
||||
println("volume unavailable (output closed) — skipping idle-push volume demo")
|
||||
return@runBlocking
|
||||
}
|
||||
|
||||
// Nudge the volume on the command connection...
|
||||
val nudged = if (startVolume >= 3) startVolume - 3 else startVolume + 3
|
||||
println("setting volume $startVolume -> $nudged on the command connection...")
|
||||
client.setVolume(nudged)
|
||||
|
||||
// ...and wait for the change to come back via the idle connection.
|
||||
val observed = withTimeout(5_000) {
|
||||
client.status.first { it?.volume == nudged }
|
||||
}!!
|
||||
println("idle push observed: status flow now reports volume=${observed.volume} ✔")
|
||||
assertEquals(nudged, observed.volume)
|
||||
|
||||
// Restore the original volume and confirm that round-trips too.
|
||||
client.setVolume(startVolume)
|
||||
withTimeout(5_000) { client.status.first { it?.volume == startVolume } }
|
||||
println("restored volume to $startVolume ✔")
|
||||
} finally {
|
||||
client.disconnect()
|
||||
println("disconnected. state=${client.connectionState.value}")
|
||||
println("=== OK ===")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Drives [MpdConnection] against in-memory streams, so the whole text protocol
|
||||
* (greeting, key/value parsing, ACK handling, binary payloads) is exercised
|
||||
* with no socket.
|
||||
*/
|
||||
class MpdConnectionTest {
|
||||
|
||||
private fun conn(serverBytes: ByteArray, out: ByteArrayOutputStream = ByteArrayOutputStream()) =
|
||||
MpdConnection(ByteArrayInputStream(serverBytes), out)
|
||||
|
||||
private fun conn(serverText: String, out: ByteArrayOutputStream = ByteArrayOutputStream()) =
|
||||
conn(serverText.toByteArray(Charsets.UTF_8), out)
|
||||
|
||||
@Test
|
||||
fun handshake_parsesVersion() {
|
||||
val c = conn("OK MPD 0.23.5\n")
|
||||
assertEquals("0.23.5", c.handshake())
|
||||
assertEquals("0.23.5", c.protocolVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handshake_rejectsBadGreeting() {
|
||||
val c = conn("HELLO THERE\n")
|
||||
assertThrows(MpdConnectionException::class.java) { c.handshake() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execute_writesCommandWithNewline() {
|
||||
val out = ByteArrayOutputStream()
|
||||
val c = conn("OK MPD 0.23.5\nvolume: 50\nOK\n", out)
|
||||
c.handshake()
|
||||
c.execute("status")
|
||||
assertEquals("status\n", out.toString("UTF-8"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execute_parsesKeyValueResponse() {
|
||||
val c = conn("OK MPD 0.23.5\nvolume: 50\nstate: play\nOK\n")
|
||||
c.handshake()
|
||||
val resp = c.execute("status")
|
||||
assertEquals("50", resp["volume"])
|
||||
assertEquals("play", resp["state"])
|
||||
assertNull(resp["nope"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execute_ackBecomesTypedException() {
|
||||
val c = conn("OK MPD 0.23.5\nACK [2@0] {play} Bad song index\n")
|
||||
c.handshake()
|
||||
val ex = assertThrows(MpdAckException::class.java) { c.execute("play 99") }
|
||||
assertEquals(MpdAckException.Code.ARG, ex.code)
|
||||
assertEquals("play", ex.command)
|
||||
assertEquals("Bad song index", ex.serverMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execute_readsBinaryPayloadExactly() {
|
||||
// albumart-style response: metadata, a binary block, then OK.
|
||||
val payload = byteArrayOf(0x00, 0x10, 0x7F, 0xFF.toByte(), '\n'.code.toByte())
|
||||
val header = "OK MPD 0.23.5\nsize: 5\ntype: image/png\nbinary: 5\n"
|
||||
val trailer = "\nOK\n"
|
||||
val bytes = header.toByteArray() + payload + trailer.toByteArray()
|
||||
|
||||
val c = conn(bytes)
|
||||
c.handshake()
|
||||
val resp = c.execute("albumart foo 0")
|
||||
|
||||
assertEquals("image/png", resp["type"])
|
||||
assertArrayEquals(payload, resp.binary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun execute_throwsOnEofMidResponse() {
|
||||
val c = conn("OK MPD 0.23.5\nvolume: 50\n") // no OK terminator
|
||||
c.handshake()
|
||||
val ex = assertThrows(MpdConnectionException::class.java) { c.execute("status") }
|
||||
assertTrue(ex.message!!.contains("mid-response"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MpdProtocolTest {
|
||||
|
||||
@Test
|
||||
fun quote_wrapsPlainArg() {
|
||||
assertEquals("\"hello\"", MpdProtocol.quote("hello"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quote_escapesQuotesAndBackslashes() {
|
||||
// Input: he"llo\world -> "he\"llo\\world"
|
||||
assertEquals("\"he\\\"llo\\\\world\"", MpdProtocol.quote("he\"llo\\world"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quote_handlesEmptyString() {
|
||||
assertEquals("\"\"", MpdProtocol.quote(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun command_noArgsIsBareName() {
|
||||
assertEquals("status", MpdProtocol.command("status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun command_quotesEachArg() {
|
||||
assertEquals(
|
||||
"find \"Artist\" \"Nine Inch Nails\"",
|
||||
MpdProtocol.command("find", "Artist", "Nine Inch Nails"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commands_builderProducesExpectedWireForm() {
|
||||
assertEquals("setvol \"75\"", MpdCommands.setVolume(75))
|
||||
assertEquals("pause \"1\"", MpdCommands.pause(true))
|
||||
assertEquals("playid \"42\"", MpdCommands.playId(42))
|
||||
assertEquals("idle", MpdCommands.idle())
|
||||
assertEquals("idle \"player\" \"mixer\"", MpdCommands.idle("player", "mixer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setVolume_clampsToValidRange() {
|
||||
assertEquals("setvol \"100\"", MpdCommands.setVolume(150))
|
||||
assertEquals("setvol \"0\"", MpdCommands.setVolume(-5))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A *live* smoke test against a real MPD server. It is skipped unless `MPD_HOST`
|
||||
* is set, so the ordinary (and Nix) build never touches the network:
|
||||
*
|
||||
* ```
|
||||
* MPD_HOST=192.168.1.50 MPD_PORT=6600 [MPD_PASSWORD=secret] \
|
||||
* ./gradlew testDebugUnitTest --tests '*MpdServerIntegrationTest' --rerun-tasks
|
||||
* ```
|
||||
*
|
||||
* It connects, runs `status` / `currentsong` / `stats`, and prints the parsed
|
||||
* models so you can eyeball that the protocol layer really talks to your server.
|
||||
*/
|
||||
class MpdServerIntegrationTest {
|
||||
|
||||
private val host = System.getenv("MPD_HOST")
|
||||
private val port = System.getenv("MPD_PORT")?.toIntOrNull() ?: MpdConnection.DEFAULT_PORT
|
||||
private val password = System.getenv("MPD_PASSWORD")
|
||||
|
||||
@Test
|
||||
fun connectsAndReadsLiveState() {
|
||||
assumeTrue("set MPD_HOST to run the live MPD smoke test", host != null)
|
||||
val host = host!!
|
||||
|
||||
println("=== MPD integration smoke test: $host:$port ===")
|
||||
MpdConnection.connect(host, port, readTimeoutMs = 5_000).use { conn ->
|
||||
println("greeting: protocol version ${conn.protocolVersion}")
|
||||
assertNotNull(conn.protocolVersion)
|
||||
|
||||
if (password != null) {
|
||||
conn.execute(MpdCommands.password(password))
|
||||
println("authenticated with password")
|
||||
}
|
||||
|
||||
val status = MpdStatus.from(conn.execute(MpdCommands.status()).toMap())
|
||||
println("status : $status")
|
||||
|
||||
val song = MpdSong.from(conn.execute(MpdCommands.currentSong()).toMap())
|
||||
println("current : ${song?.let { "${it.artist} — ${it.title} (${it.uri})" } ?: "<nothing playing>"}")
|
||||
|
||||
val stats = MpdStatistics.from(conn.execute(MpdCommands.stats()).toMap())
|
||||
println("stats : ${stats.songs} songs, ${stats.albums} albums, ${stats.artists} artists")
|
||||
|
||||
// Prove the raw commands list works too — a good connectivity sanity check.
|
||||
val commands = conn.execute(MpdProtocol.command("commands")).getAll("command")
|
||||
println("server exposes ${commands.size} commands")
|
||||
}
|
||||
println("=== OK ===")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package ca.ksamad.musicremote.mpd.model
|
||||
|
||||
import ca.ksamad.musicremote.mpd.MpdResponse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MpdModelTest {
|
||||
|
||||
@Test
|
||||
fun status_parsesTypicalPlayingSnapshot() {
|
||||
val s = MpdStatus.from(
|
||||
mapOf(
|
||||
"volume" to "80",
|
||||
"repeat" to "0",
|
||||
"random" to "1",
|
||||
"single" to "oneshot",
|
||||
"consume" to "0",
|
||||
"playlist" to "17",
|
||||
"playlistlength" to "12",
|
||||
"state" to "play",
|
||||
"song" to "3",
|
||||
"songid" to "104",
|
||||
"elapsed" to "42.5",
|
||||
"duration" to "215.3",
|
||||
"bitrate" to "320",
|
||||
"audio" to "44100:16:2",
|
||||
)
|
||||
)
|
||||
assertEquals(80, s.volume)
|
||||
assertFalse(s.repeat)
|
||||
assertTrue(s.random)
|
||||
assertTrue(s.single) // "oneshot" counts as single
|
||||
assertEquals(PlayerState.PLAY, s.state)
|
||||
assertEquals(3, s.song)
|
||||
assertEquals(42.5, s.elapsed!!, 0.0001)
|
||||
assertEquals(215.3, s.duration!!, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun status_missingMixerYieldsNullVolume() {
|
||||
val s = MpdStatus.from(mapOf("volume" to "-1", "state" to "stop"))
|
||||
assertNull(s.volume)
|
||||
assertEquals(PlayerState.STOP, s.state)
|
||||
assertEquals(0, s.playlistLength)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun song_parsesTagsAndDuration() {
|
||||
val song = MpdSong.from(
|
||||
mapOf(
|
||||
"file" to "music/nin/closer.flac",
|
||||
"Title" to "Closer",
|
||||
"Artist" to "Nine Inch Nails",
|
||||
"Album" to "The Downward Spiral",
|
||||
"Track" to "6",
|
||||
"duration" to "374.146",
|
||||
"Pos" to "5",
|
||||
"Id" to "42",
|
||||
)
|
||||
)!!
|
||||
assertEquals("music/nin/closer.flac", song.uri)
|
||||
assertEquals("Closer", song.title)
|
||||
assertEquals("Nine Inch Nails", song.artist)
|
||||
assertEquals(374.146, song.duration!!, 0.0001)
|
||||
assertEquals(42, song.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun song_returnsNullWithoutFileKey() {
|
||||
assertNull(MpdSong.from(mapOf("directory" to "music/nin")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun song_fallsBackToLegacyTimeField() {
|
||||
val song = MpdSong.from(mapOf("file" to "a.mp3", "Time" to "180"))!!
|
||||
assertEquals(180.0, song.duration!!, 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun response_splitSeparatesRepeatedSongBlocks() {
|
||||
// Two songs from a playlistinfo-style response, split on "file".
|
||||
val resp = MpdResponse(
|
||||
listOf(
|
||||
"file" to "a.mp3", "Title" to "A", "Pos" to "0", "Id" to "1",
|
||||
"file" to "b.mp3", "Title" to "B", "Pos" to "1", "Id" to "2",
|
||||
)
|
||||
)
|
||||
val songs = resp.split().mapNotNull { MpdSong.from(it) }
|
||||
assertEquals(2, songs.size)
|
||||
assertEquals("A", songs[0].title)
|
||||
assertEquals("b.mp3", songs[1].uri)
|
||||
assertEquals(2, songs[1].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statistics_parseCounts() {
|
||||
val stats = MpdStatistics.from(
|
||||
mapOf(
|
||||
"artists" to "312",
|
||||
"albums" to "540",
|
||||
"songs" to "8123",
|
||||
"uptime" to "98765",
|
||||
"db_update" to "1700000000",
|
||||
)
|
||||
)
|
||||
assertEquals(312, stats.artists)
|
||||
assertEquals(8123, stats.songs)
|
||||
assertEquals(1700000000L, stats.dbUpdateEpochSeconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Top-level build file. Plugins are declared here (but not applied) so their
|
||||
// versions are shared across all modules; each module opts in via `alias(...)`.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Roadmap / TODO
|
||||
|
||||
Near-term work after the working MVP (connect + auto-connect, now-playing with
|
||||
transport/volume/options, DataStore-persisted settings). Roughly ordered by
|
||||
priority; not a commitment.
|
||||
|
||||
Status legend: `[ ]` todo · `[~]` in progress · `[x]` done
|
||||
|
||||
---
|
||||
|
||||
## [ ] 1. Proper icons
|
||||
|
||||
Replace the placeholder unicode glyphs (`GlyphText` in `NowPlayingScreen.kt` —
|
||||
`⏮ ▶ ⏸ ⏭ 🔈`) with real Material icons.
|
||||
|
||||
- Preferred source: **Google Material Symbols / Icons**. In Compose the usual
|
||||
route is the `androidx.compose.material:material-icons-*` artifacts
|
||||
(`Icons.Filled.PlayArrow`, `SkipNext`, `VolumeUp`, …). `material-icons-core`
|
||||
covers the common set; `material-icons-extended` has everything but is large —
|
||||
prefer core, or import only the specific vector assets we use.
|
||||
- Platform-bundled drawables (`android.R.drawable.ic_media_*`) exist but look
|
||||
dated and vary by OEM — avoid; bundle our own for a consistent look.
|
||||
- Adding the dependency means a `deps.json` regen (see README).
|
||||
|
||||
## [ ] 2. Settings menu
|
||||
|
||||
A dedicated settings screen where the user can view every setting and reset it.
|
||||
|
||||
- Currently settings (host/port) are only editable by hitting **Disconnect** to
|
||||
get back to the connect form. Add a real settings route.
|
||||
- Include a **Reset** action that clears DataStore (add a `clear()` to
|
||||
`SettingsRepository`).
|
||||
- Will likely want simple navigation (Navigation-Compose, or a screen enum in
|
||||
`PlayerViewModel`/`AppScreen`).
|
||||
- Room to grow: password field, connection timeout, theme, keep-screen-on.
|
||||
|
||||
## [ ] 3. Cast-style volume control
|
||||
|
||||
When the MPD server is playing, present the volume control as a **"casting"
|
||||
style** remote-volume control — the way MALP (and Google Cast) do — making it
|
||||
visually clear you're controlling the *server's* output, not the phone's.
|
||||
|
||||
- Functionally we already send `setvol` to the server (`MpdClient.setVolume`);
|
||||
this is mostly a UX/affordance change: a cast icon, "Casting to <host>" label,
|
||||
distinct styling for remote vs local volume.
|
||||
- Reference behaviour: MALP.
|
||||
|
||||
## [ ] 4. Library browse — albums
|
||||
|
||||
A screen to browse all albums on the server (artists can come later).
|
||||
|
||||
- MPD commands: `list album group albumartist` (or `list album`), and
|
||||
`find album "<name>"` to fetch an album's tracks; add to the queue with
|
||||
`add`/`findadd`. Extend `MpdCommands` + `MpdClient`.
|
||||
- Grid or list of albums → tap to view/queue tracks.
|
||||
|
||||
## [ ] 5. Album / artist images
|
||||
|
||||
Pull artwork for the now-playing track and for the album browse grid.
|
||||
|
||||
- MPD serves art over the protocol via `albumart <uri> <offset>` and
|
||||
`readpicture <uri> <offset>` (embedded art). **`MpdConnection` already handles
|
||||
binary responses**, so the transport groundwork is done — add the commands,
|
||||
loop over offsets to fetch the whole image, and decode.
|
||||
- Needs an image loader + caching. Coil (`io.coil-kt`) is the standard Compose
|
||||
choice; a custom `MpdArtFetcher` could feed it. Dependency → `deps.json` regen.
|
||||
|
||||
## [ ] 6. BUG: idle connection drops after a few minutes → kicked to connect page
|
||||
|
||||
After a few minutes idling, the app surfaces **"connection closed mid-response"**
|
||||
and falls back to the connect screen. MALP does not do this.
|
||||
|
||||
- Error origin: `MpdConnection.readResponse()` hits EOF and throws
|
||||
`MpdConnectionException("connection closed mid-response")`; the idle loop's
|
||||
`catch (IOException)` calls `failAndClose()` → `MpdConnectionState.Error` →
|
||||
UI shows the connect form.
|
||||
- Likely causes to investigate:
|
||||
- **Android Doze / WiFi power-save** tearing down sockets when the screen is
|
||||
off or the app is backgrounded (most likely on a portable DAP/phone).
|
||||
- MPD's `connection_timeout` (default 60s) closing a connection it considers
|
||||
idle — a parked `idle` should count as active, but the *command* connection
|
||||
sits silent; a periodic `ping` keepalive may be needed.
|
||||
- NAT/router idle-connection reaping (less likely on LAN).
|
||||
- Fix direction: don't treat an idle-connection drop as a fatal error — instead
|
||||
**auto-reconnect transparently** (re-open connections, re-issue `idle`, resync
|
||||
state) and keep showing the player. Consider a keepalive ping and, for
|
||||
backgrounded playback control, a foreground service / partial wakelock.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Testing on a physical Android device
|
||||
|
||||
The fastest inner loop for this app is building the debug APK and installing it
|
||||
straight onto a real device over USB (an Android phone, or an Android-based DAP
|
||||
like the FiiO M33). This is quicker than `nix run .#emulate` and tests real
|
||||
networking to your MPD server.
|
||||
|
||||
## One-time device setup
|
||||
|
||||
1. **Enable Developer Options**: Settings → About phone → tap **Build number**
|
||||
seven times.
|
||||
2. **Enable USB debugging**: Settings → System → Developer options → **USB
|
||||
debugging**.
|
||||
3. **Use a data-capable USB cable** and plug the device directly into the
|
||||
machine. Many cables are charge-only — if the device never appears in
|
||||
`lsusb`, suspect the cable or the USB mode first.
|
||||
4. **Unlock the device screen.** The *"Allow USB debugging?"* authorization
|
||||
dialog only appears while unlocked — this is the usual reason "no popup shows
|
||||
up". Tap **Allow** and check **Always allow from this computer**.
|
||||
|
||||
### NixOS note (this machine)
|
||||
|
||||
`adb` is provided by the dev shell (`nix develop`). On this NixOS host it talks
|
||||
to devices **without** needing `programs.adb.enable`, custom udev rules, or an
|
||||
`adbusers` group: a plugged-in, authorized device shows up directly as `device`.
|
||||
|
||||
If a device instead shows as:
|
||||
|
||||
- `unauthorized` → you haven't accepted the on-device prompt yet (unlock the
|
||||
screen and tap Allow).
|
||||
- `no permissions` → *then* you'd need udev rules; add
|
||||
`programs.adb.enable = true;` and your user to the `adbusers` group in the
|
||||
NixOS config, `nixos-rebuild switch`, re-login, and `adb kill-server`. (Not
|
||||
currently needed here.)
|
||||
|
||||
## Verify the device is connected
|
||||
|
||||
```sh
|
||||
adb devices -l # should list the device as `device` (not unauthorized)
|
||||
adb shell getprop ro.product.model
|
||||
```
|
||||
|
||||
If `adb devices` is empty, check `lsusb` for the device (Pixels show as
|
||||
`Google Inc. …` / vendor `18d1`), then re-check the cable and USB debugging.
|
||||
|
||||
## Build, install, launch
|
||||
|
||||
From the repo root, inside `nix develop`:
|
||||
|
||||
```sh
|
||||
./gradlew assembleDebug
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
adb shell am start -n ca.ksamad.musicremote/.MainActivity # optional auto-launch
|
||||
```
|
||||
|
||||
`-r` reinstalls over the existing app **keeping its data** (so persisted
|
||||
connection settings survive). To simulate a clean first run:
|
||||
|
||||
```sh
|
||||
adb shell pm clear ca.ksamad.musicremote
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
```sh
|
||||
adb exec-out screencap -p > screenshot.png
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"failed to connect to <host>:<port>"** — the device can't reach the MPD
|
||||
server. Check the device is on the **same WiFi/LAN** as the server. A common
|
||||
gotcha: WiFi is off on a portable DAP, so `adb shell ip route` shows no route
|
||||
and `adb shell ping <server>` returns *"Network is unreachable"*.
|
||||
- **App looks stale after changes** — reinstall the APK; restarting the device
|
||||
or emulator alone does not update the installed app.
|
||||
|
||||
## Wireless alternative (no cable)
|
||||
|
||||
Android 11+ supports wireless debugging (Developer options → **Wireless
|
||||
debugging** → *Pair device with pairing code*), then on the host:
|
||||
|
||||
```sh
|
||||
adb pair <ip>:<pair-port> # enter the code shown on-device
|
||||
adb connect <ip>:<debug-port>
|
||||
```
|
||||
|
||||
This needs no cable and no udev rules; the device must share your LAN.
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1784796856,
|
||||
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config = {
|
||||
# The Android SDK components and Android Studio are unfree, and the
|
||||
# SDK requires accepting its license non-interactively.
|
||||
allowUnfree = true;
|
||||
android_sdk.accept_license = true;
|
||||
};
|
||||
};
|
||||
|
||||
buildToolsVersion = "35.0.0";
|
||||
|
||||
# A minimal SDK: just the pieces needed to compile & assemble the app.
|
||||
# (No emulator/system-image here — those live in the `emulate` output.)
|
||||
androidComposition = pkgs.androidenv.composeAndroidPackages {
|
||||
platformVersions = [ "35" ];
|
||||
buildToolsVersions = [ buildToolsVersion ];
|
||||
includeEmulator = false;
|
||||
includeSystemImages = false;
|
||||
includeNDK = false;
|
||||
};
|
||||
|
||||
androidSdk = androidComposition.androidsdk;
|
||||
androidHome = "${androidSdk}/libexec/android-sdk";
|
||||
|
||||
# The built app, referenced by both `packages.default` and the emulator.
|
||||
musicRemote = pkgs.callPackage ./package.nix {
|
||||
inherit androidSdk androidHome buildToolsVersion;
|
||||
};
|
||||
applicationId = "ca.ksamad.musicremote";
|
||||
in
|
||||
{
|
||||
formatter = pkgs.nixfmt-rfc-style;
|
||||
|
||||
# `nix build` -> reproducible debug APK in ./result/music-remote.apk
|
||||
packages.default = musicRemote;
|
||||
|
||||
# `nix develop` -> command-line dev shell (gradle + SDK wired up).
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.jdk17
|
||||
pkgs.gradle
|
||||
androidSdk
|
||||
];
|
||||
|
||||
ANDROID_HOME = androidHome;
|
||||
ANDROID_SDK_ROOT = androidHome;
|
||||
|
||||
# Same NixOS aapt2 fix as in package.nix, but as a Gradle system prop
|
||||
# so plain `gradle assembleDebug` works inside the shell.
|
||||
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2";
|
||||
|
||||
shellHook = ''
|
||||
echo "music-remote dev shell — try: gradle assembleDebug"
|
||||
'';
|
||||
};
|
||||
|
||||
# `nix develop .#studio` -> full Android Studio (GUI + emulator manager).
|
||||
devShells.studio = pkgs.mkShell {
|
||||
buildInputs = [ pkgs.androidStudioPackages.stable ];
|
||||
};
|
||||
|
||||
# `nix run .#emulate` -> boots an emulator, then installs and launches the
|
||||
# app on it. `app` points at the built package directory (the script globs
|
||||
# `*.apk` inside it); `package`/`activity` make it auto-start after boot.
|
||||
packages.emulate = pkgs.androidenv.emulateApp {
|
||||
name = "emulate-MusicRemote";
|
||||
platformVersion = "35";
|
||||
abiVersion = "x86_64";
|
||||
# Bare AOSP image. This app only needs the base Android framework
|
||||
# (TCP/HTTP networking for the MPD client); it uses no Google Play
|
||||
# Services APIs, so `google_apis`/`_playstore` would just be extra
|
||||
# weight. Switch to "google_apis" only if you add a Google SDK.
|
||||
systemImageType = "default";
|
||||
|
||||
app = musicRemote;
|
||||
package = applicationId;
|
||||
activity = "${applicationId}.MainActivity";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# JVM args for the Gradle daemon.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
|
||||
# Use AndroidX (not the old support library).
|
||||
android.useAndroidX=true
|
||||
|
||||
# Kotlin official code style.
|
||||
kotlin.code.style=official
|
||||
|
||||
# Generate per-module R classes (smaller, faster builds).
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,34 @@
|
||||
# Gradle "version catalog": one place for every dependency + plugin version.
|
||||
# Referenced from build scripts as `libs.plugins.*` and `libs.*`.
|
||||
|
||||
[versions]
|
||||
agp = "8.7.3" # Android Gradle Plugin
|
||||
kotlin = "2.0.21" # Kotlin (its version also drives the Compose compiler plugin)
|
||||
coreKtx = "1.13.1"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
composeBom = "2024.10.01" # Compose Bill-of-Materials: pins all Compose lib versions together
|
||||
coroutines = "1.9.0" # kotlinx-coroutines (async I/O + Flow for the MPD client)
|
||||
datastore = "1.1.1" # Jetpack DataStore (persisting connection settings)
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# https://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.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
@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
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Reproducible build of the debug APK, written the way a nixpkgs package would be.
|
||||
#
|
||||
# Two hard problems this file solves:
|
||||
# 1. The Android SDK -> supplied via `androidSdk` (built by androidenv in flake.nix)
|
||||
# and exposed through ANDROID_HOME.
|
||||
# 2. Gradle's network -> `gradle` with `mitmCache` intercepts Gradle's HTTP(S)
|
||||
# dependency fetches traffic and replays it from a hash-locked `deps.json`,
|
||||
# so the build itself runs fully offline & reproducibly.
|
||||
#
|
||||
# Regenerate deps.json whenever dependencies change:
|
||||
# nix build .#default.mitmCache.updateScript && ./result
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
gradle,
|
||||
makeWrapper,
|
||||
androidSdk,
|
||||
androidHome,
|
||||
buildToolsVersion ? "35.0.0",
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "music-remote";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
gradle
|
||||
androidSdk
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
ANDROID_HOME = androidHome;
|
||||
# Some tooling still reads the deprecated variable; set both to be safe.
|
||||
ANDROID_SDK_ROOT = androidHome;
|
||||
|
||||
# Gradle downloads a prebuilt aapt2 from Maven that isn't patched for NixOS's
|
||||
# loader. Point AGP at the aapt2 that androidenv already patched instead.
|
||||
gradleFlags = [
|
||||
"-Pandroid.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2"
|
||||
];
|
||||
|
||||
# Build the (unsigned) debug APK. `assembleRelease` would additionally need a
|
||||
# signing keystore, which is out of scope for a hello-world.
|
||||
gradleBuildTask = "assembleDebug";
|
||||
|
||||
# Run the JVM unit tests as the derivation's check phase.
|
||||
doCheck = true;
|
||||
gradleCheckTask = "testDebugUnitTest";
|
||||
|
||||
mitmCache = gradle.fetchDeps {
|
||||
# `pkg` (rather than the manual's `inherit pname`) is what an out-of-tree
|
||||
# flake package must pass: the manual's shorthand resolves `pkgs.<pname>`,
|
||||
# which only exists for packages that live inside nixpkgs itself.
|
||||
pkg = finalAttrs.finalPackage;
|
||||
data = ./deps.json;
|
||||
};
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm644 app/build/outputs/apk/debug/app-debug.apk \
|
||||
"$out/music-remote.apk"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Hello-world Kotlin / Jetpack Compose (Material 3) Android app";
|
||||
platforms = lib.platforms.linux;
|
||||
license = lib.licenses.mit;
|
||||
};
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "MusicRemote"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user