chore: rename package from music-remote to encore.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Music Remote
|
||||
# Encore
|
||||
|
||||
A minimal, modern Android app: **Kotlin** + **Jetpack Compose** (Material 3), built and
|
||||
tested with **Nix**.
|
||||
@@ -20,7 +20,7 @@ app/
|
||||
build.gradle.kts The :app module (Android + Compose config)
|
||||
src/main/
|
||||
AndroidManifest.xml App/activity declaration
|
||||
kotlin/ca/ksamad/musicremote/
|
||||
kotlin/ca/ksamad/encore/
|
||||
MainActivity.kt Entry activity; sets the Compose content
|
||||
Theme.kt Material 3 theme (dynamic color)
|
||||
res/values/ strings.xml, themes.xml
|
||||
@@ -66,7 +66,7 @@ nix run .#emulate
|
||||
## Reproducible Build with Nix
|
||||
|
||||
```sh
|
||||
nix build # -> ./result/music-remote.apk
|
||||
nix build # -> ./result/encore.apk
|
||||
```
|
||||
|
||||
Unlike `gradle assembleDebug`, this build runs **fully offline**: every Gradle/Maven
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ca.ksamad.musicremote"
|
||||
namespace = "ca.ksamad.encore"
|
||||
compileSdk = 35
|
||||
|
||||
// Pin the build-tools to exactly what Nix supplies. Without this, AGP picks
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
buildToolsVersion = "35.0.0"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ca.ksamad.musicremote"
|
||||
applicationId = "ca.ksamad.encore"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".MusicRemoteApplication"
|
||||
android:name=".EncoreApplication"
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.MusicRemote">
|
||||
android:theme="@style/Theme.Encore">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
package ca.ksamad.musicremote
|
||||
package ca.ksamad.encore
|
||||
|
||||
import android.app.Application
|
||||
import ca.ksamad.musicremote.playback.ArtImageLoader
|
||||
import ca.ksamad.musicremote.playback.MpdConnectionManager
|
||||
import ca.ksamad.encore.playback.ArtImageLoader
|
||||
import ca.ksamad.encore.playback.MpdConnectionManager
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
@@ -10,12 +10,12 @@ import coil3.SingletonImageLoader
|
||||
/**
|
||||
* Holds the app-scoped [MpdConnectionManager] so the MPD connection outlives any
|
||||
* 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
|
||||
* 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) }
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote
|
||||
package ca.ksamad.encore
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
@@ -16,8 +16,8 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import ca.ksamad.musicremote.playback.VolumeKeyDispatcher
|
||||
import ca.ksamad.musicremote.ui.MusicRemoteApp
|
||||
import ca.ksamad.encore.playback.VolumeKeyDispatcher
|
||||
import ca.ksamad.encore.ui.EncoreApp
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@@ -27,7 +27,7 @@ class MainActivity : ComponentActivity() {
|
||||
// Intercept the hardware volume keys while we're focused so they drive the
|
||||
// server volume without the system slider appearing (see VolumeKeyDispatcher).
|
||||
private val volumeKeys by lazy {
|
||||
VolumeKeyDispatcher((application as MusicRemoteApplication).manager)
|
||||
VolumeKeyDispatcher((application as EncoreApplication).manager)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
maybeRequestNotificationPermission()
|
||||
setContent {
|
||||
MusicRemoteTheme {
|
||||
EncoreTheme {
|
||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -43,7 +43,7 @@ class MainActivity : ComponentActivity() {
|
||||
.padding(innerPadding),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
MusicRemoteApp()
|
||||
EncoreApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote
|
||||
package ca.ksamad.encore
|
||||
|
||||
import android.os.Build
|
||||
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.
|
||||
*/
|
||||
@Composable
|
||||
fun MusicRemoteTheme(
|
||||
fun EncoreTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.data
|
||||
package ca.ksamad.encore.data
|
||||
|
||||
/** The persisted MPD server the app connects to. */
|
||||
data class ConnectionSettings(
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.data
|
||||
package ca.ksamad.encore.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
+12
-6
@@ -1,10 +1,10 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||
import ca.ksamad.encore.mpd.model.PlayerState
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -164,6 +164,12 @@ class MpdClient(
|
||||
suspend fun stop() = run(MpdCommands.stop())
|
||||
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. */
|
||||
suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri))
|
||||
|
||||
+1
-1
@@ -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,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
+1
-1
@@ -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. */
|
||||
sealed interface MpdConnectionState {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
/**
|
||||
* Pure, connection-independent helpers for speaking the MPD text protocol:
|
||||
+1
-1
@@ -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`
|
||||
+2
-2
@@ -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. */
|
||||
data class MpdAlbum(
|
||||
+1
-1
@@ -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`,
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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`. */
|
||||
enum class PlayerState {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.playback
|
||||
package ca.ksamad.encore.playback
|
||||
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
+1
-1
@@ -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
|
||||
+14
-8
@@ -1,13 +1,13 @@
|
||||
package ca.ksamad.musicremote.playback
|
||||
package ca.ksamad.encore.playback
|
||||
|
||||
import android.content.Context
|
||||
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 ca.ksamad.musicremote.mpd.model.MpdAlbum
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
||||
import ca.ksamad.encore.data.ConnectionSettings
|
||||
import ca.ksamad.encore.data.SettingsRepository
|
||||
import ca.ksamad.encore.mpd.MpdClient
|
||||
import ca.ksamad.encore.mpd.MpdConnectionState
|
||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -160,6 +160,12 @@ class MpdConnectionManager(context: Context) {
|
||||
/** Jump to a queue entry by its stable song id. */
|
||||
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. */
|
||||
suspend fun loadAlbums(): List<MpdAlbum> = runCatching { client.albums() }.getOrDefault(emptyList())
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.playback
|
||||
package ca.ksamad.encore.playback
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -22,13 +22,13 @@ import coil3.SingletonImageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.toBitmap
|
||||
import ca.ksamad.musicremote.MainActivity
|
||||
import ca.ksamad.musicremote.MusicRemoteApplication
|
||||
import ca.ksamad.musicremote.R
|
||||
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
||||
import ca.ksamad.encore.MainActivity
|
||||
import ca.ksamad.encore.EncoreApplication
|
||||
import ca.ksamad.encore.R
|
||||
import ca.ksamad.encore.mpd.MpdConnectionState
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||
import ca.ksamad.encore.mpd.model.PlayerState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -52,7 +52,7 @@ import kotlinx.coroutines.launch
|
||||
class PlaybackService : Service() {
|
||||
|
||||
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 volumeProvider: VolumeProviderCompat
|
||||
@@ -62,7 +62,7 @@ class PlaybackService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
session = MediaSessionCompat(this, "MusicRemote").apply {
|
||||
session = MediaSessionCompat(this, "Encore").apply {
|
||||
setCallback(mediaCallback)
|
||||
isActive = true
|
||||
}
|
||||
@@ -218,7 +218,7 @@ class PlaybackService : Service() {
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_music_note)
|
||||
.setContentTitle(song?.title ?: song?.uri ?: "Music Remote")
|
||||
.setContentTitle(song?.title ?: song?.uri ?: "Encore")
|
||||
.setContentText(song?.artist ?: "")
|
||||
.setSubText(song?.album)
|
||||
.setContentIntent(contentIntent)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.playback
|
||||
package ca.ksamad.encore.playback
|
||||
|
||||
import android.view.KeyEvent
|
||||
|
||||
+57
-22
@@ -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.Box
|
||||
@@ -40,10 +40,9 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.playback.AlbumArt
|
||||
import ca.ksamad.musicremote.playback.SongArt
|
||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.playback.AlbumArt
|
||||
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
|
||||
when {
|
||||
loaded == null -> item {
|
||||
@@ -121,18 +136,15 @@ fun AlbumDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
else -> items(loaded, key = { it.uri }) { 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")
|
||||
},
|
||||
)
|
||||
// Single disc (or untagged): a plain flat list.
|
||||
loaded.map(::discNumberOf).distinct().size <= 1 ->
|
||||
items(loaded, key = { it.uri }) { trackItem(it) }
|
||||
|
||||
// Multi-disc: a light "Disc N" header before each disc's tracks. The
|
||||
// list arrives already sorted by (disc, track), so groupBy keeps order.
|
||||
else -> loaded.groupBy(::discNumberOf).forEach { (disc, discTracks) ->
|
||||
item(key = "disc-$disc") { DiscHeader(disc) }
|
||||
items(discTracks, key = { it.uri }) { trackItem(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +202,9 @@ private fun AlbumDetailHeader(
|
||||
|
||||
/**
|
||||
* 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
|
||||
private fun TrackRow(
|
||||
@@ -201,11 +215,13 @@ private fun TrackRow(
|
||||
QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
ArtImage(
|
||||
model = SongArt(track.uri),
|
||||
iconSize = 18.dp,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)),
|
||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
trackNumberOf(track)?.toString() ?: "•",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
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
|
||||
BIN
Binary file not shown.
+2
-2
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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
|
||||
* 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
|
||||
* [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
|
||||
fun ArtImage(model: Any?, iconSize: Dp, modifier: Modifier = Modifier) {
|
||||
+5
-5
@@ -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.Column
|
||||
@@ -25,8 +25,8 @@ 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
|
||||
import ca.ksamad.musicremote.mpd.model.MpdAlbum
|
||||
import ca.ksamad.encore.data.ConnectionSettings
|
||||
import ca.ksamad.encore.mpd.model.MpdAlbum
|
||||
|
||||
/**
|
||||
* App root. Renders whichever [AppScreen] the [PlayerViewModel] decides on: a
|
||||
@@ -43,7 +43,7 @@ private val AlbumSaver = listSaver<MpdAlbum?, String?>(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MusicRemoteApp(vm: PlayerViewModel = viewModel()) {
|
||||
fun EncoreApp(vm: PlayerViewModel = viewModel()) {
|
||||
val screen by vm.screen.collectAsStateWithLifecycle()
|
||||
var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) }
|
||||
// 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,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("Music Remote", style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Encore", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"Connect to your MPD server",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
+24
-4
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import ca.ksamad.musicremote.playback.SongArt
|
||||
import ca.ksamad.musicremote.mpd.model.PlayerState
|
||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||
import ca.ksamad.encore.playback.SongArt
|
||||
import ca.ksamad.encore.mpd.model.PlayerState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
@@ -125,6 +125,22 @@ fun NowPlayingScreen(
|
||||
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)
|
||||
|
||||
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 {
|
||||
val total = seconds.toInt().coerceAtLeast(0)
|
||||
val m = total / 60
|
||||
+8
-6
@@ -1,11 +1,11 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import ca.ksamad.musicremote.MusicRemoteApplication
|
||||
import ca.ksamad.musicremote.data.ConnectionSettings
|
||||
import ca.ksamad.musicremote.mpd.MpdConnectionState
|
||||
import ca.ksamad.encore.EncoreApplication
|
||||
import ca.ksamad.encore.data.ConnectionSettings
|
||||
import ca.ksamad.encore.mpd.MpdConnectionState
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -25,14 +25,14 @@ sealed interface AppScreen {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* lives in the manager (shared with the foreground service), so it survives this
|
||||
* ViewModel being cleared on Activity recreation.
|
||||
*/
|
||||
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 currentSong = manager.currentSong
|
||||
@@ -79,6 +79,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
suspend fun loadAlbums() = manager.loadAlbums()
|
||||
|
||||
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()
|
||||
suspend fun loadStatistics() = manager.loadStatistics()
|
||||
suspend fun loadQueue() = manager.loadQueue()
|
||||
+132
-23
@@ -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.layout.Box
|
||||
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.material.icons.Icons
|
||||
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.VolumeUp
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -22,14 +24,22 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import ca.ksamad.musicremote.playback.SongArt
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||
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
|
||||
@@ -62,10 +73,16 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
||||
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.
|
||||
val currentPos = status?.song
|
||||
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)
|
||||
|
||||
@@ -73,6 +90,28 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
||||
var confirmClear by remember { mutableStateOf(false) }
|
||||
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(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -91,6 +130,7 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
when {
|
||||
upcoming == null -> Box(
|
||||
@@ -119,8 +159,44 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
||||
items(upcoming, key = { it.id ?: it.uri }) { song ->
|
||||
// First item is the currently-playing track (when there is one).
|
||||
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(
|
||||
modifier = Modifier.clickable { song.id?.let { vm.playQueueItem(it) } },
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
colors = if (isCurrent) {
|
||||
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
} else {
|
||||
@@ -150,26 +226,59 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
|
||||
song.duration?.let { { Text(formatDuration(it)) } }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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") }
|
||||
/**
|
||||
* Wraps [content] in a swipe-to-remove gesture: a swipe in either direction
|
||||
* settles the row off-screen and calls [onRemove]. The reveal is a red trash
|
||||
* background on whichever edge is being swiped from.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SwipeToRemoveRow(onRemove: () -> Unit, content: @Composable () -> Unit) {
|
||||
val state = rememberSwipeToDismissBoxState(
|
||||
confirmValueChange = { target ->
|
||||
if (target != SwipeToDismissBoxValue.Settled) {
|
||||
onRemove()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -36,7 +36,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.util.Date
|
||||
import java.util.Locale
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.ui
|
||||
package ca.ksamad.encore.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Music Remote</string>
|
||||
<string name="app_name">Encore</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
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" />
|
||||
<style name="Theme.Encore" parent="@android:style/Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote
|
||||
package ca.ksamad.encore
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
package ca.ksamad.musicremote.mpd
|
||||
package ca.ksamad.encore.mpd
|
||||
|
||||
import ca.ksamad.musicremote.mpd.model.MpdSong
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatistics
|
||||
import ca.ksamad.musicremote.mpd.model.MpdStatus
|
||||
import ca.ksamad.encore.mpd.model.MpdSong
|
||||
import ca.ksamad.encore.mpd.model.MpdStatistics
|
||||
import ca.ksamad.encore.mpd.model.MpdStatus
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
+2
-2
@@ -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.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
+2
-2
@@ -49,7 +49,7 @@ form) and **Reset settings** (confirm dialog → clears DataStore + disconnects)
|
||||
|
||||
- `SettingsRepository.clear()` wipes DataStore; `MpdConnectionManager.resetSettings()`
|
||||
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
|
||||
Navigation-Compose dependency yet.
|
||||
- **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:
|
||||
|
||||
- **`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
|
||||
runs while the service is up. `PlayerViewModel` is a thin delegate over it.
|
||||
- **`PlaybackService`** — a `mediaPlayback` foreground service hosting a
|
||||
|
||||
@@ -50,14 +50,14 @@ From the repo root, inside `nix develop`:
|
||||
```sh
|
||||
./gradlew assembleDebug
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
adb shell am start -n ca.ksamad.musicremote/.MainActivity # optional auto-launch
|
||||
adb shell am start -n ca.ksamad.encore/.MainActivity # optional auto-launch
|
||||
```
|
||||
|
||||
`-r` reinstalls over the existing app **keeping its data** (so persisted
|
||||
connection settings survive). To simulate a clean first run:
|
||||
|
||||
```sh
|
||||
adb shell pm clear ca.ksamad.musicremote
|
||||
adb shell pm clear ca.ksamad.encore
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -42,12 +42,12 @@
|
||||
musicRemote = pkgs.callPackage ./package.nix {
|
||||
inherit androidSdk androidHome buildToolsVersion;
|
||||
};
|
||||
applicationId = "ca.ksamad.musicremote";
|
||||
applicationId = "ca.ksamad.encore";
|
||||
in
|
||||
{
|
||||
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;
|
||||
|
||||
# `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";
|
||||
|
||||
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
|
||||
# `*.apk` inside it); `package`/`activity` make it auto-start after boot.
|
||||
packages.emulate = pkgs.androidenv.emulateApp {
|
||||
name = "emulate-MusicRemote";
|
||||
name = "emulate-Encore";
|
||||
platformVersion = "35";
|
||||
abiVersion = "x86_64";
|
||||
# Bare AOSP image. This app only needs the base Android framework
|
||||
|
||||
@@ -9,3 +9,8 @@ kotlin.code.style=official
|
||||
|
||||
# Generate per-module R classes (smaller, faster builds).
|
||||
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
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "music-remote";
|
||||
pname = "encore";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
@@ -60,7 +60,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm644 app/build/outputs/apk/debug/app-debug.apk \
|
||||
"$out/music-remote.apk"
|
||||
"$out/encore.apk"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
|
||||
+1
-1
@@ -20,5 +20,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "MusicRemote"
|
||||
rootProject.name = "Encore"
|
||||
include(":app")
|
||||
|
||||
Reference in New Issue
Block a user