Initial MVP

This commit is contained in:
2026-07-27 00:16:46 -04:00
commit 5930630b2e
44 changed files with 4381 additions and 0 deletions
+112
View File
@@ -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)
}
+22
View File
@@ -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()
}
}
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Music Remote</string>
</resources>
+9
View File
@@ -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)
}
}