chore: rename package from music-remote to encore.

This commit is contained in:
2026-07-30 00:47:30 -04:00
parent 7d4dd0ff00
commit d8977deaf3
48 changed files with 347 additions and 164 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
# Music Remote # Encore
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
tested with **Nix**. tested with **Nix**.
@@ -20,7 +20,7 @@ app/
build.gradle.kts The :app module (Android + Compose config) build.gradle.kts The :app module (Android + Compose config)
src/main/ src/main/
AndroidManifest.xml App/activity declaration AndroidManifest.xml App/activity declaration
kotlin/ca/ksamad/musicremote/ kotlin/ca/ksamad/encore/
MainActivity.kt Entry activity; sets the Compose content MainActivity.kt Entry activity; sets the Compose content
Theme.kt Material 3 theme (dynamic color) Theme.kt Material 3 theme (dynamic color)
res/values/ strings.xml, themes.xml res/values/ strings.xml, themes.xml
@@ -66,7 +66,7 @@ nix run .#emulate
## Reproducible Build with Nix ## Reproducible Build with Nix
```sh ```sh
nix build # -> ./result/music-remote.apk nix build # -> ./result/encore.apk
``` ```
Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
} }
android { android {
namespace = "ca.ksamad.musicremote" namespace = "ca.ksamad.encore"
compileSdk = 35 compileSdk = 35
// Pin the build-tools to exactly what Nix supplies. Without this, AGP picks // Pin the build-tools to exactly what Nix supplies. Without this, AGP picks
@@ -14,7 +14,7 @@ android {
buildToolsVersion = "35.0.0" buildToolsVersion = "35.0.0"
defaultConfig { defaultConfig {
applicationId = "ca.ksamad.musicremote" applicationId = "ca.ksamad.encore"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
+2 -2
View File
@@ -10,11 +10,11 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".MusicRemoteApplication" android:name=".EncoreApplication"
android:allowBackup="true" android:allowBackup="true"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MusicRemote"> android:theme="@style/Theme.Encore">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true">
@@ -1,8 +1,8 @@
package ca.ksamad.musicremote package ca.ksamad.encore
import android.app.Application import android.app.Application
import ca.ksamad.musicremote.playback.ArtImageLoader import ca.ksamad.encore.playback.ArtImageLoader
import ca.ksamad.musicremote.playback.MpdConnectionManager import ca.ksamad.encore.playback.MpdConnectionManager
import coil3.ImageLoader import coil3.ImageLoader
import coil3.PlatformContext import coil3.PlatformContext
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
@@ -10,12 +10,12 @@ import coil3.SingletonImageLoader
/** /**
* Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any * Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any
* single Activity and can be shared with the foreground * single Activity and can be shared with the foreground
* [ca.ksamad.musicremote.playback.PlaybackService]. * [ca.ksamad.encore.playback.PlaybackService].
* *
* Also the Coil [SingletonImageLoader.Factory], wiring cover-art loading to that * Also the Coil [SingletonImageLoader.Factory], wiring cover-art loading to that
* same manager so `AsyncImage` calls anywhere in the app fetch (and cache) MPD art. * same manager so `AsyncImage` calls anywhere in the app fetch (and cache) MPD art.
*/ */
class MusicRemoteApplication : Application(), SingletonImageLoader.Factory { class EncoreApplication : Application(), SingletonImageLoader.Factory {
val manager: MpdConnectionManager by lazy { MpdConnectionManager(this) } val manager: MpdConnectionManager by lazy { MpdConnectionManager(this) }
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote package ca.ksamad.encore
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -16,8 +16,8 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import ca.ksamad.musicremote.playback.VolumeKeyDispatcher import ca.ksamad.encore.playback.VolumeKeyDispatcher
import ca.ksamad.musicremote.ui.MusicRemoteApp import ca.ksamad.encore.ui.EncoreApp
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -27,7 +27,7 @@ class MainActivity : ComponentActivity() {
// Intercept the hardware volume keys while we're focused so they drive the // Intercept the hardware volume keys while we're focused so they drive the
// server volume without the system slider appearing (see VolumeKeyDispatcher). // server volume without the system slider appearing (see VolumeKeyDispatcher).
private val volumeKeys by lazy { private val volumeKeys by lazy {
VolumeKeyDispatcher((application as MusicRemoteApplication).manager) VolumeKeyDispatcher((application as EncoreApplication).manager)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
maybeRequestNotificationPermission() maybeRequestNotificationPermission()
setContent { setContent {
MusicRemoteTheme { EncoreTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
.padding(innerPadding), .padding(innerPadding),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
MusicRemoteApp() EncoreApp()
} }
} }
} }
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote package ca.ksamad.encore
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -17,7 +17,7 @@ import androidx.compose.ui.platform.LocalContext
* wallpaper); on older versions it falls back to a default light/dark scheme. * wallpaper); on older versions it falls back to a default light/dark scheme.
*/ */
@Composable @Composable
fun MusicRemoteTheme( fun EncoreTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
content: @Composable () -> Unit, content: @Composable () -> Unit,
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.data package ca.ksamad.encore.data
/** The persisted MPD server the app connects to. */ /** The persisted MPD server the app connects to. */
data class ConnectionSettings( data class ConnectionSettings(
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.data package ca.ksamad.encore.data
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
@@ -1,10 +1,10 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
import ca.ksamad.musicremote.mpd.model.MpdAlbum import ca.ksamad.encore.mpd.model.MpdAlbum
import ca.ksamad.musicremote.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.musicremote.mpd.model.MpdStatistics import ca.ksamad.encore.mpd.model.MpdStatistics
import ca.ksamad.musicremote.mpd.model.MpdStatus import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.musicremote.mpd.model.PlayerState import ca.ksamad.encore.mpd.model.PlayerState
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -164,6 +164,12 @@ class MpdClient(
suspend fun stop() = run(MpdCommands.stop()) suspend fun stop() = run(MpdCommands.stop())
suspend fun clearQueue() = run(MpdCommands.clear()) suspend fun clearQueue() = run(MpdCommands.clear())
/** Remove a queue entry by its stable song id. */
suspend fun removeQueueItem(songId: Int) = run(MpdCommands.deleteId(songId))
/** Insert a single track at an absolute queue position (used to undo a removal). */
suspend fun addTrackAt(uri: String, position: Int) = run(MpdCommands.add(uri, position.toString()))
/** Append a single track to the end of the queue; playback is untouched. */ /** Append a single track to the end of the queue; playback is untouched. */
suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri)) suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri))
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
/** /**
* Typed builders for the command lines we send. These return the assembled, * Typed builders for the command lines we send. These return the assembled,
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
/** Lifecycle of an [MpdClient]'s link to a server, surfaced as observable state. */ /** Lifecycle of an [MpdClient]'s link to a server, surfaced as observable state. */
sealed interface MpdConnectionState { sealed interface MpdConnectionState {
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
import java.io.IOException import java.io.IOException
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
/** /**
* Pure, connection-independent helpers for speaking the MPD text protocol: * Pure, connection-independent helpers for speaking the MPD text protocol:
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
/** /**
* The parsed result of a single successful command: the ordered `key: value` * The parsed result of a single successful command: the ordered `key: value`
@@ -1,6 +1,6 @@
package ca.ksamad.musicremote.mpd.model package ca.ksamad.encore.mpd.model
import ca.ksamad.musicremote.mpd.MpdResponse import ca.ksamad.encore.mpd.MpdResponse
/** An album in the library, optionally attributed to an album artist. */ /** An album in the library, optionally attributed to an album artist. */
data class MpdAlbum( data class MpdAlbum(
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd.model package ca.ksamad.encore.mpd.model
/** /**
* A song/file entry, parsed from the metadata block MPD emits for `currentsong`, * A song/file entry, parsed from the metadata block MPD emits for `currentsong`,
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd.model package ca.ksamad.encore.mpd.model
/** /**
* Database/server statistics from a `stats` response. Durations are in seconds * Database/server statistics from a `stats` response. Durations are in seconds
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd.model package ca.ksamad.encore.mpd.model
/** Player transport state as reported by the `state` field of `status`. */ /** Player transport state as reported by the `state` field of `status`. */
enum class PlayerState { enum class PlayerState {
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.playback package ca.ksamad.encore.playback
import coil3.ImageLoader import coil3.ImageLoader
import coil3.PlatformContext import coil3.PlatformContext
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.playback package ca.ksamad.encore.playback
/** /**
* A request for a piece of MPD cover art, used as the model handed to Coil. The * A request for a piece of MPD cover art, used as the model handed to Coil. The
@@ -1,13 +1,13 @@
package ca.ksamad.musicremote.playback package ca.ksamad.encore.playback
import android.content.Context import android.content.Context
import ca.ksamad.musicremote.data.ConnectionSettings import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.musicremote.data.SettingsRepository import ca.ksamad.encore.data.SettingsRepository
import ca.ksamad.musicremote.mpd.MpdClient import ca.ksamad.encore.mpd.MpdClient
import ca.ksamad.musicremote.mpd.MpdConnectionState import ca.ksamad.encore.mpd.MpdConnectionState
import ca.ksamad.musicremote.mpd.model.MpdAlbum import ca.ksamad.encore.mpd.model.MpdAlbum
import ca.ksamad.musicremote.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.musicremote.mpd.model.MpdStatistics import ca.ksamad.encore.mpd.model.MpdStatistics
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -160,6 +160,12 @@ class MpdConnectionManager(context: Context) {
/** Jump to a queue entry by its stable song id. */ /** Jump to a queue entry by its stable song id. */
fun playQueueItem(songId: Int) = fire { playId(songId) } fun playQueueItem(songId: Int) = fire { playId(songId) }
/** Remove a queue entry by its stable song id. */
fun removeQueueItem(songId: Int) = fire { removeQueueItem(songId) }
/** Re-insert a track at an absolute queue position (undo a removal). */
fun addTrackAt(uri: String, position: Int) = fire { addTrackAt(uri, position) }
/** One-shot library fetch; returns empty on any failure. */ /** One-shot library fetch; returns empty on any failure. */
suspend fun loadAlbums(): List<MpdAlbum> = runCatching { client.albums() }.getOrDefault(emptyList()) suspend fun loadAlbums(): List<MpdAlbum> = runCatching { client.albums() }.getOrDefault(emptyList())
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.playback package ca.ksamad.encore.playback
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -22,13 +22,13 @@ import coil3.SingletonImageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.SuccessResult import coil3.request.SuccessResult
import coil3.toBitmap import coil3.toBitmap
import ca.ksamad.musicremote.MainActivity import ca.ksamad.encore.MainActivity
import ca.ksamad.musicremote.MusicRemoteApplication import ca.ksamad.encore.EncoreApplication
import ca.ksamad.musicremote.R import ca.ksamad.encore.R
import ca.ksamad.musicremote.mpd.MpdConnectionState import ca.ksamad.encore.mpd.MpdConnectionState
import ca.ksamad.musicremote.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.musicremote.mpd.model.MpdStatus import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.musicremote.mpd.model.PlayerState import ca.ksamad.encore.mpd.model.PlayerState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -52,7 +52,7 @@ import kotlinx.coroutines.launch
class PlaybackService : Service() { class PlaybackService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val manager get() = (application as MusicRemoteApplication).manager private val manager get() = (application as EncoreApplication).manager
private lateinit var session: MediaSessionCompat private lateinit var session: MediaSessionCompat
private lateinit var volumeProvider: VolumeProviderCompat private lateinit var volumeProvider: VolumeProviderCompat
@@ -62,7 +62,7 @@ class PlaybackService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
session = MediaSessionCompat(this, "MusicRemote").apply { session = MediaSessionCompat(this, "Encore").apply {
setCallback(mediaCallback) setCallback(mediaCallback)
isActive = true isActive = true
} }
@@ -218,7 +218,7 @@ class PlaybackService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_music_note) .setSmallIcon(R.drawable.ic_music_note)
.setContentTitle(song?.title ?: song?.uri ?: "Music Remote") .setContentTitle(song?.title ?: song?.uri ?: "Encore")
.setContentText(song?.artist ?: "") .setContentText(song?.artist ?: "")
.setSubText(song?.album) .setSubText(song?.album)
.setContentIntent(contentIntent) .setContentIntent(contentIntent)
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.playback package ca.ksamad.encore.playback
import android.view.KeyEvent import android.view.KeyEvent
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -40,10 +40,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ca.ksamad.musicremote.mpd.model.MpdAlbum import ca.ksamad.encore.mpd.model.MpdAlbum
import ca.ksamad.musicremote.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.musicremote.playback.AlbumArt import ca.ksamad.encore.playback.AlbumArt
import ca.ksamad.musicremote.playback.SongArt
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -100,6 +99,22 @@ fun AlbumDetailScreen(
) )
} }
// One track row, wired to its swipe actions. Reused whether the list is
// flat or split into disc groups.
val trackItem: @Composable (MpdSong) -> Unit = { track ->
TrackRow(
track = track,
onQueue = {
vm.queueTrack(track.uri)
flash("Added “${track.title ?: track.uri}” to the queue")
},
onPlayNext = {
vm.playTrackNext(track.uri)
flash("${track.title ?: track.uri}” will play next")
},
)
}
val loaded = tracks val loaded = tracks
when { when {
loaded == null -> item { loaded == null -> item {
@@ -121,18 +136,15 @@ fun AlbumDetailScreen(
} }
} }
else -> items(loaded, key = { it.uri }) { track -> // Single disc (or untagged): a plain flat list.
TrackRow( loaded.map(::discNumberOf).distinct().size <= 1 ->
track = track, items(loaded, key = { it.uri }) { trackItem(it) }
onQueue = {
vm.queueTrack(track.uri) // Multi-disc: a light "Disc N" header before each disc's tracks. The
flash("Added “${track.title ?: track.uri}” to the queue") // list arrives already sorted by (disc, track), so groupBy keeps order.
}, else -> loaded.groupBy(::discNumberOf).forEach { (disc, discTracks) ->
onPlayNext = { item(key = "disc-$disc") { DiscHeader(disc) }
vm.playTrackNext(track.uri) items(discTracks, key = { it.uri }) { trackItem(it) }
flash("${track.title ?: track.uri}” will play next")
},
)
} }
} }
} }
@@ -190,7 +202,9 @@ private fun AlbumDetailHeader(
/** /**
* A single track row. Swiping right queues the track, swiping left plays it next * A single track row. Swiping right queues the track, swiping left plays it next
* the same gesture as the album library, scoped to this one track. * the same gesture as the album library, scoped to this one track. The leading
* slot shows the track number (all tracks share the album's cover, so a per-track
* thumbnail would be redundant here).
*/ */
@Composable @Composable
private fun TrackRow( private fun TrackRow(
@@ -201,11 +215,13 @@ private fun TrackRow(
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) { QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
ListItem( ListItem(
leadingContent = { leadingContent = {
ArtImage( Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
model = SongArt(track.uri), Text(
iconSize = 18.dp, trackNumberOf(track)?.toString() ?: "",
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}
}, },
headlineContent = { headlineContent = {
Text(track.title ?: track.uri, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(track.title ?: track.uri, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -217,3 +233,22 @@ private fun TrackRow(
) )
} }
} }
/** A light section header separating discs in a multi-disc album. */
@Composable
private fun DiscHeader(disc: Int) {
Text(
"Disc $disc",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
)
}
/** Leading track number from a `Track` tag (`"3"`, `"3/12"`, …), or null if absent. */
private fun trackNumberOf(track: MpdSong): Int? =
track.track?.takeWhile { it.isDigit() }?.toIntOrNull()
/** Leading disc number from a `Disc` tag; defaults to 1 when untagged. */
private fun discNumberOf(track: MpdSong): Int =
track.disc?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 1
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -19,7 +19,7 @@ import coil3.compose.AsyncImage
* Cover art with a disc-icon placeholder. Draws the placeholder underneath and * Cover art with a disc-icon placeholder. Draws the placeholder underneath and
* lets the [AsyncImage] paint over it once (and if) art loads so a missing * lets the [AsyncImage] paint over it once (and if) art loads so a missing
* cover, a still-loading fetch, and a solid image all look right. [model] is an * cover, a still-loading fetch, and a solid image all look right. [model] is an
* [ca.ksamad.musicremote.playback.MpdArtData] (or null to show just the placeholder). * [ca.ksamad.encore.playback.MpdArtData] (or null to show just the placeholder).
*/ */
@Composable @Composable
fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) { fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) {
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -25,8 +25,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import ca.ksamad.musicremote.data.ConnectionSettings import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.musicremote.mpd.model.MpdAlbum import ca.ksamad.encore.mpd.model.MpdAlbum
/** /**
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a * App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
@@ -43,7 +43,7 @@ private val AlbumSaver = listSaver<MpdAlbum?, String?>(
) )
@Composable @Composable
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) { fun EncoreApp(vm: PlayerViewModel = viewModel()) {
val screen by vm.screen.collectAsStateWithLifecycle() val screen by vm.screen.collectAsStateWithLifecycle()
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) } var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
// When set (within the Albums overlay), the album detail screen is shown. // When set (within the Albums overlay), the album detail screen is shown.
@@ -125,7 +125,7 @@ private fun ConnectScreen(vm: PlayerViewModel, saved: ConnectionSettings, error:
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text("Music Remote", style = MaterialTheme.typography.headlineMedium) Text("Encore", style = MaterialTheme.typography.headlineMedium)
Text( Text(
"Connect to your MPD server", "Connect to your MPD server",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -49,9 +49,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ca.ksamad.musicremote.mpd.model.MpdStatus import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.musicremote.playback.SongArt import ca.ksamad.encore.playback.SongArt
import ca.ksamad.musicremote.mpd.model.PlayerState import ca.ksamad.encore.mpd.model.PlayerState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/** /**
@@ -125,6 +125,22 @@ fun NowPlayingScreen(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
// Descriptive metadata (release year · genre), when the tags are present.
val descriptors = listOfNotNull(
song?.date?.let(::releaseYear),
song?.genre?.takeIf { it.isNotBlank() },
)
if (descriptors.isNotEmpty()) {
Text(
text = descriptors.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
AudioPropertyPills(status = status) AudioPropertyPills(status = status)
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
@@ -380,6 +396,10 @@ private fun VolumeControl(volume: Int?, onSetVolume: (Int) -> Unit) {
} }
} }
/** The 4-digit year from an MPD `Date` tag (`"2019"`, `"2019-05-03"`, …), or null. */
private fun releaseYear(date: String): String? =
Regex("\\d{4}").find(date)?.value
private fun formatTime(seconds: Double): String { private fun formatTime(seconds: Double): String {
val total = seconds.toInt().coerceAtLeast(0) val total = seconds.toInt().coerceAtLeast(0)
val m = total / 60 val m = total / 60
@@ -1,11 +1,11 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import ca.ksamad.musicremote.MusicRemoteApplication import ca.ksamad.encore.EncoreApplication
import ca.ksamad.musicremote.data.ConnectionSettings import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.musicremote.mpd.MpdConnectionState import ca.ksamad.encore.mpd.MpdConnectionState
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -25,14 +25,14 @@ sealed interface AppScreen {
/** /**
* Thin UI-facing layer over the app-scoped * Thin UI-facing layer over the app-scoped
* [ca.ksamad.musicremote.playback.MpdConnectionManager]: it exposes the manager's * [ca.ksamad.encore.playback.MpdConnectionManager]: it exposes the manager's
* flows to Compose and derives the top-level [AppScreen]. The connection itself * flows to Compose and derives the top-level [AppScreen]. The connection itself
* lives in the manager (shared with the foreground service), so it survives this * lives in the manager (shared with the foreground service), so it survives this
* ViewModel being cleared on Activity recreation. * ViewModel being cleared on Activity recreation.
*/ */
class PlayerViewModel(application: Application) : AndroidViewModel(application) { class PlayerViewModel(application: Application) : AndroidViewModel(application) {
private val manager = (application as MusicRemoteApplication).manager private val manager = (application as EncoreApplication).manager
val status = manager.status val status = manager.status
val currentSong = manager.currentSong val currentSong = manager.currentSong
@@ -79,6 +79,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
suspend fun loadAlbums() = manager.loadAlbums() suspend fun loadAlbums() = manager.loadAlbums()
fun playQueueItem(songId: Int) = manager.playQueueItem(songId) fun playQueueItem(songId: Int) = manager.playQueueItem(songId)
fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId)
fun addTrackAt(uri: String, position: Int) = manager.addTrackAt(uri, position)
fun clearQueue() = manager.clearQueue() fun clearQueue() = manager.clearQueue()
suspend fun loadStatistics() = manager.loadStatistics() suspend fun loadStatistics() = manager.loadStatistics()
suspend fun loadQueue() = manager.loadQueue() suspend fun loadQueue() = manager.loadQueue()
@@ -1,5 +1,6 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -11,6 +12,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -22,14 +24,22 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -38,9 +48,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ca.ksamad.musicremote.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.musicremote.mpd.model.MpdStatus import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.musicremote.playback.SongArt import ca.ksamad.encore.playback.SongArt
import kotlinx.coroutines.launch
/** /**
* "Up next": the tracks that will auto-play from the current one to the end of * "Up next": the tracks that will auto-play from the current one to the end of
@@ -62,10 +73,16 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
value = vm.loadQueue() value = vm.loadQueue()
} }
// Ids swiped away locally, hidden immediately so the row leaves without waiting
// for the server round-trip. Reset on every reload — by then the fresh queue
// already reflects the change (or, if the delete failed, restores the track).
var pendingRemoval by remember(fullQueue) { mutableStateOf(emptySet<Int>()) }
// Slice from the current song to the end. `song` is the current queue index. // Slice from the current song to the end. `song` is the current queue index.
val currentPos = status?.song val currentPos = status?.song
val upcoming: List<MpdSong>? = fullQueue?.let { q -> val upcoming: List<MpdSong>? = fullQueue?.let { q ->
if (currentPos != null && currentPos in q.indices) q.subList(currentPos, q.size) else q val slice = if (currentPos != null && currentPos in q.indices) q.subList(currentPos, q.size) else q
if (pendingRemoval.isEmpty()) slice else slice.filter { it.id !in pendingRemoval }
} }
val hasCurrent = currentPos != null && (fullQueue?.indices?.contains(currentPos) == true) val hasCurrent = currentPos != null && (fullQueue?.indices?.contains(currentPos) == true)
@@ -73,6 +90,28 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
var confirmClear by remember { mutableStateOf(false) } var confirmClear by remember { mutableStateOf(false) }
val queueNotEmpty = fullQueue?.isNotEmpty() == true val queueNotEmpty = fullQueue?.isNotEmpty() == true
// Undo affordance for swipe-to-remove.
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
fun removeSong(song: MpdSong) {
val id = song.id ?: return
pendingRemoval = pendingRemoval + id
vm.removeQueueItem(id)
snackbarHostState.currentSnackbarData?.dismiss()
scope.launch {
val result = snackbarHostState.showSnackbar(
message = "Removed “${song.title ?: song.uri}",
actionLabel = "Undo",
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed) {
// Re-insert at its original index; fall back to append if unknown.
song.pos?.let { vm.addTrackAt(song.uri, it) } ?: vm.queueTrack(song.uri)
pendingRemoval = pendingRemoval - id
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -91,6 +130,7 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
}, },
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
when { when {
upcoming == null -> Box( upcoming == null -> Box(
@@ -119,8 +159,44 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
items(upcoming, key = { it.id ?: it.uri }) { song -> items(upcoming, key = { it.id ?: it.uri }) { song ->
// First item is the currently-playing track (when there is one). // First item is the currently-playing track (when there is one).
val isCurrent = hasCurrent && song === upcoming.first() val isCurrent = hasCurrent && song === upcoming.first()
val id = song.id
// The current track and id-less entries aren't swipe-removable —
// removing the current one would disrupt playback.
if (isCurrent || id == null) {
QueueRow(song, isCurrent, onClick = { id?.let { vm.playQueueItem(it) } })
} else {
SwipeToRemoveRow(onRemove = { removeSong(song) }) {
QueueRow(song, isCurrent = false, onClick = { vm.playQueueItem(id) })
}
}
}
}
}
}
if (confirmClear) {
AlertDialog(
onDismissRequest = { confirmClear = false },
title = { Text("Clear the queue?") },
text = { Text("This removes every track from the queue and stops playback.") },
confirmButton = {
TextButton(onClick = {
vm.clearQueue()
confirmClear = false
}) { Text("Clear") }
},
dismissButton = {
TextButton(onClick = { confirmClear = false }) { Text("Cancel") }
},
)
}
}
/** A single queue entry — art, title/artist, and a duration or now-playing badge. */
@Composable
private fun QueueRow(song: MpdSong, isCurrent: Boolean, onClick: () -> Unit) {
ListItem( ListItem(
modifier = Modifier.clickable { song.id?.let { vm.playQueueItem(it) } }, modifier = Modifier.clickable(onClick = onClick),
colors = if (isCurrent) { colors = if (isCurrent) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else { } else {
@@ -150,26 +226,59 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
song.duration?.let { { Text(formatDuration(it)) } } song.duration?.let { { Text(formatDuration(it)) } }
}, },
) )
} }
}
}
}
if (confirmClear) { /**
AlertDialog( * Wraps [content] in a swipe-to-remove gesture: a swipe in either direction
onDismissRequest = { confirmClear = false }, * settles the row off-screen and calls [onRemove]. The reveal is a red trash
title = { Text("Clear the queue?") }, * background on whichever edge is being swiped from.
text = { Text("This removes every track from the queue and stops playback.") }, */
confirmButton = { @OptIn(ExperimentalMaterial3Api::class)
TextButton(onClick = { @Composable
vm.clearQueue() private fun SwipeToRemoveRow(onRemove: () -> Unit, content: @Composable () -> Unit) {
confirmClear = false val state = rememberSwipeToDismissBoxState(
}) { Text("Clear") } confirmValueChange = { target ->
}, if (target != SwipeToDismissBoxValue.Settled) {
dismissButton = { onRemove()
TextButton(onClick = { confirmClear = false }) { Text("Cancel") } true // Commit the dismiss; the row is also filtered from the list.
} else {
false
}
}, },
) )
SwipeToDismissBox(
state = state,
backgroundContent = { RemoveBackground(state.dismissDirection) },
) {
content()
}
}
/** Red "delete" reveal behind a swiping queue row; empty while settled. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RemoveBackground(direction: SwipeToDismissBoxValue) {
if (direction == SwipeToDismissBoxValue.Settled) {
Box(Modifier.fillMaxSize())
return
}
val alignment = if (direction == SwipeToDismissBoxValue.StartToEnd) {
Alignment.CenterStart
} else {
Alignment.CenterEnd
}
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 24.dp),
contentAlignment = alignment,
) {
Icon(
Icons.Filled.Delete,
contentDescription = "Remove from queue",
tint = MaterialTheme.colorScheme.onErrorContainer,
)
} }
} }
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -36,7 +36,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ca.ksamad.musicremote.mpd.model.MpdStatistics import ca.ksamad.encore.mpd.model.MpdStatistics
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">Music Remote</string> <string name="app_name">Encore</string>
</resources> </resources>
+1 -1
View File
@@ -5,5 +5,5 @@
Using a platform parent avoids pulling in the extra Material Components Using a platform parent avoids pulling in the extra Material Components
XML library that we don't need for a Compose-only app. XML library that we don't need for a Compose-only app.
--> -->
<style name="Theme.MusicRemote" parent="@android:style/Theme.Material.Light.NoActionBar" /> <style name="Theme.Encore" parent="@android:style/Theme.Material.Light.NoActionBar" />
</resources> </resources>
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote package ca.ksamad.encore
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -1,8 +1,8 @@
package ca.ksamad.musicremote.mpd package ca.ksamad.encore.mpd
import ca.ksamad.musicremote.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.musicremote.mpd.model.MpdStatistics import ca.ksamad.encore.mpd.model.MpdStatistics
import ca.ksamad.musicremote.mpd.model.MpdStatus import ca.ksamad.encore.mpd.model.MpdStatus
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
import org.junit.Test import org.junit.Test
@@ -1,6 +1,6 @@
package ca.ksamad.musicremote.mpd.model package ca.ksamad.encore.mpd.model
import ca.ksamad.musicremote.mpd.MpdResponse import ca.ksamad.encore.mpd.MpdResponse
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
+2 -2
View File
@@ -49,7 +49,7 @@ form) and **Reset settings** (confirm dialog → clears DataStore + disconnects)
- `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()` - `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()`
clears + disconnects. clears + disconnects.
- Navigation is lightweight state (`showSettings` in `MusicRemoteApp`, only over - Navigation is lightweight state (`showSettings` in `EncoreApp`, only over
the player screen; a `LaunchedEffect` drops it when leaving the player) — no the player screen; a `LaunchedEffect` drops it when leaving the player) — no
Navigation-Compose dependency yet. Navigation-Compose dependency yet.
- **Playback section**: a **Consume mode** toggle (live-bound to `status.consume`, - **Playback section**: a **Consume mode** toggle (live-bound to `status.consume`,
@@ -64,7 +64,7 @@ form) and **Reset settings** (confirm dialog → clears DataStore + disconnects)
Done — full MALP-style **OS integration**, not just in-app. Architecture: Done — full MALP-style **OS integration**, not just in-app. Architecture:
- **`MpdConnectionManager`** (app-scoped, held by `MusicRemoteApplication`) now - **`MpdConnectionManager`** (app-scoped, held by `EncoreApplication`) now
owns the single `MpdClient`, so the connection survives Activity recreation and owns the single `MpdClient`, so the connection survives Activity recreation and
runs while the service is up. `PlayerViewModel` is a thin delegate over it. runs while the service is up. `PlayerViewModel` is a thin delegate over it.
- **`PlaybackService`** — a `mediaPlayback` foreground service hosting a - **`PlaybackService`** — a `mediaPlayback` foreground service hosting a
+2 -2
View File
@@ -50,14 +50,14 @@ From the repo root, inside `nix develop`:
```sh ```sh
./gradlew assembleDebug ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n ca.ksamad.musicremote/.MainActivity # optional auto-launch adb shell am start -n ca.ksamad.encore/.MainActivity # optional auto-launch
``` ```
`-r` reinstalls over the existing app **keeping its data** (so persisted `-r` reinstalls over the existing app **keeping its data** (so persisted
connection settings survive). To simulate a clean first run: connection settings survive). To simulate a clean first run:
```sh ```sh
adb shell pm clear ca.ksamad.musicremote adb shell pm clear ca.ksamad.encore
``` ```
## Screenshots ## Screenshots
+4 -4
View File
@@ -42,12 +42,12 @@
musicRemote = pkgs.callPackage ./package.nix { musicRemote = pkgs.callPackage ./package.nix {
inherit androidSdk androidHome buildToolsVersion; inherit androidSdk androidHome buildToolsVersion;
}; };
applicationId = "ca.ksamad.musicremote"; applicationId = "ca.ksamad.encore";
in in
{ {
formatter = pkgs.nixfmt-rfc-style; formatter = pkgs.nixfmt-rfc-style;
# `nix build` -> reproducible debug APK in ./result/music-remote.apk # `nix build` -> reproducible debug APK in ./result/encore.apk
packages.default = musicRemote; packages.default = musicRemote;
# `nix develop` -> command-line dev shell (gradle + SDK wired up). # `nix develop` -> command-line dev shell (gradle + SDK wired up).
@@ -66,7 +66,7 @@
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2"; GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidHome}/build-tools/${buildToolsVersion}/aapt2";
shellHook = '' shellHook = ''
echo "music-remote dev shell try: gradle assembleDebug" echo "encore dev shell try: gradle assembleDebug"
''; '';
}; };
@@ -79,7 +79,7 @@
# app on it. `app` points at the built package directory (the script globs # app on it. `app` points at the built package directory (the script globs
# `*.apk` inside it); `package`/`activity` make it auto-start after boot. # `*.apk` inside it); `package`/`activity` make it auto-start after boot.
packages.emulate = pkgs.androidenv.emulateApp { packages.emulate = pkgs.androidenv.emulateApp {
name = "emulate-MusicRemote"; name = "emulate-Encore";
platformVersion = "35"; platformVersion = "35";
abiVersion = "x86_64"; abiVersion = "x86_64";
# Bare AOSP image. This app only needs the base Android framework # Bare AOSP image. This app only needs the base Android framework
+5
View File
@@ -9,3 +9,8 @@ kotlin.code.style=official
# Generate per-module R classes (smaller, faster builds). # Generate per-module R classes (smaller, faster builds).
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# The Nix build (package.nix / flake.nix) sets android.aapt2FromMavenOverride to
# point AGP at the NixOS-patched aapt2. AGP flags that option as experimental;
# suppress the warning since the override is intentional and required here.
android.suppressUnsupportedOptionWarnings=android.aapt2FromMavenOverride
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+2 -2
View File
@@ -20,7 +20,7 @@
}: }:
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = "music-remote"; pname = "encore";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
@@ -60,7 +60,7 @@ stdenv.mkDerivation (finalAttrs: {
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
install -Dm644 app/build/outputs/apk/debug/app-debug.apk \ install -Dm644 app/build/outputs/apk/debug/app-debug.apk \
"$out/music-remote.apk" "$out/encore.apk"
runHook postInstall runHook postInstall
''; '';
+1 -1
View File
@@ -20,5 +20,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "MusicRemote" rootProject.name = "Encore"
include(":app") include(":app")