From 5930630b2e7ecf239292d1884a03002e5103f9d4 Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Sun, 26 Jul 2026 10:23:32 -0400 Subject: [PATCH] Initial MVP --- .envrc | 1 + .gitignore | 20 + README.md | 108 ++ app/build.gradle.kts | 112 ++ app/src/main/AndroidManifest.xml | 22 + .../ca/ksamad/musicremote/MainActivity.kt | 34 + .../kotlin/ca/ksamad/musicremote/Theme.kt | 39 + .../musicremote/data/ConnectionSettings.kt | 12 + .../musicremote/data/SettingsRepository.kt | 49 + .../ca/ksamad/musicremote/mpd/MpdClient.kt | 245 +++ .../ca/ksamad/musicremote/mpd/MpdCommands.kt | 69 + .../ksamad/musicremote/mpd/MpdConnection.kt | 172 ++ .../musicremote/mpd/MpdConnectionState.kt | 16 + .../ca/ksamad/musicremote/mpd/MpdException.kt | 72 + .../ca/ksamad/musicremote/mpd/MpdProtocol.kt | 52 + .../ca/ksamad/musicremote/mpd/MpdResponse.kt | 53 + .../ksamad/musicremote/mpd/model/MpdSong.kt | 51 + .../musicremote/mpd/model/MpdStatistics.kt | 27 + .../ksamad/musicremote/mpd/model/MpdStatus.kt | 64 + .../ksamad/musicremote/ui/MusicRemoteApp.kt | 119 ++ .../ksamad/musicremote/ui/NowPlayingScreen.kt | 220 +++ .../ksamad/musicremote/ui/PlayerViewModel.kt | 118 ++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 9 + .../ca/ksamad/musicremote/ExampleUnitTest.kt | 16 + .../mpd/MpdClientIntegrationTest.kt | 68 + .../musicremote/mpd/MpdConnectionTest.kt | 90 + .../ksamad/musicremote/mpd/MpdProtocolTest.kt | 51 + .../mpd/MpdServerIntegrationTest.kt | 58 + .../musicremote/mpd/model/MpdModelTest.kt | 113 ++ build.gradle.kts | 7 + deps.json | 1469 +++++++++++++++++ docs/TODO.md | 87 + docs/device-testing.md | 88 + flake.lock | 61 + flake.nix | 97 ++ gradle.properties | 11 + gradle/libs.versions.toml | 34 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++ gradlew.bat | 93 ++ package.nix | 72 + settings.gradle.kts | 24 + 44 files changed, 4381 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/Theme.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/data/ConnectionSettings.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnection.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionState.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdProtocol.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdResponse.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdSong.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatistics.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatus.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt create mode 100644 app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/kotlin/ca/ksamad/musicremote/ExampleUnitTest.kt create mode 100644 app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdClientIntegrationTest.kt create mode 100644 app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionTest.kt create mode 100644 app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdProtocolTest.kt create mode 100644 app/src/test/kotlin/ca/ksamad/musicremote/mpd/MpdServerIntegrationTest.kt create mode 100644 app/src/test/kotlin/ca/ksamad/musicremote/mpd/model/MpdModelTest.kt create mode 100644 build.gradle.kts create mode 100644 deps.json create mode 100644 docs/TODO.md create mode 100644 docs/device-testing.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 package.nix create mode 100644 settings.gradle.kts diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f1193f --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Gradle +.gradle/ +build/ + +# Android +*.apk +*.aab +local.properties + +# Nix +result +result-* + +# IDE +.idea/ +*.iml +.kotlin/ + +# Environment mappings +.direnv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..634131e --- /dev/null +++ b/README.md @@ -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`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..bec5bad --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1b6b93d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt b/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt new file mode 100644 index 0000000..98bc3c3 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/MainActivity.kt @@ -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() + } + } + } + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/Theme.kt b/app/src/main/kotlin/ca/ksamad/musicremote/Theme.kt new file mode 100644 index 0000000..2caeb81 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/data/ConnectionSettings.kt b/app/src/main/kotlin/ca/ksamad/musicremote/data/ConnectionSettings.kt new file mode 100644 index 0000000..52efb61 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/data/ConnectionSettings.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt b/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt new file mode 100644 index 0000000..d301202 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/data/SettingsRepository.kt @@ -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 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 = 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 = settingsOrNull.map { it ?: ConnectionSettings.DEFAULT } + + suspend fun save(settings: ConnectionSettings) { + context.dataStore.edit { prefs -> + prefs[Keys.HOST] = settings.host + prefs[Keys.PORT] = settings.port + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt new file mode 100644 index 0000000..a19a3ec --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdClient.kt @@ -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.Disconnected) + val connectionState = _connectionState.asStateFlow() + + private val _status = MutableStateFlow(null) + val status = _status.asStateFlow() + + private val _currentSong = MutableStateFlow(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 = 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 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") + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt new file mode 100644 index 0000000..45e64e3 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdCommands.kt @@ -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()) +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnection.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnection.kt new file mode 100644 index 0000000..480e57a --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnection.kt @@ -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 `). 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>() + 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) + } + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionState.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionState.kt new file mode 100644 index 0000000..d069394 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdConnectionState.kt @@ -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 +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt new file mode 100644 index 0000000..8eb3f17 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdException.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdProtocol.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdProtocol.kt new file mode 100644 index 0000000..329afda --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdProtocol.kt @@ -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)) + } + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdResponse.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdResponse.kt new file mode 100644 index 0000000..5f3659b --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/MpdResponse.kt @@ -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>, + 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 = + 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 { + val out = LinkedHashMap(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> { + val result = ArrayList>() + var current: LinkedHashMap? = null + for ((k, v) in values) { + if (k == delimiter) { + current = LinkedHashMap() + result.add(current) + } + current?.putIfAbsent(k, v) + } + return result + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdSong.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdSong.kt new file mode 100644 index 0000000..49139d9 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdSong.kt @@ -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): 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(), + ) + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatistics.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatistics.kt new file mode 100644 index 0000000..5e31fe3 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatistics.kt @@ -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): 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, + ) + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatus.kt b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatus.kt new file mode 100644 index 0000000..64262dd --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/mpd/model/MpdStatus.kt @@ -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): 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"], + ) + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt new file mode 100644 index 0000000..f3a1f5a --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/MusicRemoteApp.kt @@ -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), + ) + } + } +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt new file mode 100644 index 0000000..3741a87 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/NowPlayingScreen.kt @@ -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(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(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) +} diff --git a/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt new file mode 100644 index 0000000..ac38ec2 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/musicremote/ui/PlayerViewModel.kt @@ -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 = 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() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0343ca7 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Music Remote + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..af991e0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + +