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
+2 -2
View File
@@ -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">
@@ -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) }
@@ -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()
}
}
}
@@ -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,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,4 +1,4 @@
package ca.ksamad.musicremote.data
package ca.ksamad.encore.data
import android.content.Context
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.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,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,4 +1,4 @@
package ca.ksamad.musicremote.mpd
package ca.ksamad.encore.mpd
import java.io.BufferedInputStream
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. */
sealed interface MpdConnectionState {
@@ -1,4 +1,4 @@
package ca.ksamad.musicremote.mpd
package ca.ksamad.encore.mpd
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:
@@ -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`
@@ -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,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,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,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,4 +1,4 @@
package ca.ksamad.musicremote.playback
package ca.ksamad.encore.playback
import coil3.ImageLoader
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
@@ -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())
@@ -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,4 +1,4 @@
package ca.ksamad.musicremote.playback
package ca.ksamad.encore.playback
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.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
@@ -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) {
@@ -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,
@@ -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
@@ -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()
@@ -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,37 +159,16 @@ 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()
ListItem(
modifier = Modifier.clickable { song.id?.let { vm.playQueueItem(it) } },
colors = if (isCurrent) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
ListItemDefaults.colors()
},
leadingContent = {
ArtImage(
model = SongArt(song.uri),
iconSize = 20.dp,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)),
)
},
headlineContent = {
Text(
song.title ?: song.uri,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal,
)
},
supportingContent = song.artist?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
trailingContent = if (isCurrent) {
{ Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") }
} else {
song.duration?.let { { Text(formatDuration(it)) } }
},
)
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) })
}
}
}
}
}
@@ -173,6 +192,96 @@ fun QueueScreen(vm: PlayerViewModel, onBack: () -> Unit) {
}
}
/** 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(onClick = onClick),
colors = if (isCurrent) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
ListItemDefaults.colors()
},
leadingContent = {
ArtImage(
model = SongArt(song.uri),
iconSize = 20.dp,
modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)),
)
},
headlineContent = {
Text(
song.title ?: song.uri,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Normal,
)
},
supportingContent = song.artist?.let {
{ Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
},
trailingContent = if (isCurrent) {
{ Icon(Icons.Filled.VolumeUp, contentDescription = "Now playing") }
} else {
song.duration?.let { { Text(formatDuration(it)) } }
},
)
}
/**
* 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,
)
}
}
/** Explains what will actually happen at the end of the list, given the modes. */
private fun playbackModeCaption(status: MpdStatus?): String? = when {
status == null -> null
@@ -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,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 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">Music Remote</string>
<string name="app_name">Encore</string>
</resources>
+1 -1
View File
@@ -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,4 +1,4 @@
package ca.ksamad.musicremote
package ca.ksamad.encore
import org.junit.Assert.assertEquals
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.runBlocking
@@ -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,4 +1,4 @@
package ca.ksamad.musicremote.mpd
package ca.ksamad.encore.mpd
import org.junit.Assert.assertEquals
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.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
@@ -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