Initial MVP
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user