diff --git a/app/src/main/kotlin/ca/ksamad/encore/MainActivity.kt b/app/src/main/kotlin/ca/ksamad/encore/MainActivity.kt index c042e85..cd017d4 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/MainActivity.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/MainActivity.kt @@ -4,52 +4,40 @@ import android.Manifest import android.content.pm.PackageManager import android.os.Build import android.os.Bundle -import android.view.KeyEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat -import ca.ksamad.encore.playback.VolumeKeyDispatcher import ca.ksamad.encore.ui.EncoreApp class MainActivity : ComponentActivity() { private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ } - // 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 EncoreApplication).manager) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() maybeRequestNotificationPermission() setContent { EncoreTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Surface( - modifier = Modifier.fillMaxSize().padding(innerPadding), - color = MaterialTheme.colorScheme.background, - ) { - EncoreApp() - } + // No inset padding here: each screen's own Scaffold/TopAppBar (or, for the + // plain-Column screens, their own systemBarsPadding) applies the system-bar + // insets. Doing it at the root too would double the status-bar gap. + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + EncoreApp() } } } } - override fun dispatchKeyEvent(event: KeyEvent): Boolean = - volumeKeys.dispatch(event) || super.dispatchKeyEvent(event) - // The media notification needs POST_NOTIFICATIONS on Android 13+. Without it // the foreground service still runs and cast volume still works, but the // now-playing notification / QS controls won't show. diff --git a/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt index 3f2856c..dafc7d6 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt @@ -26,6 +26,8 @@ class SettingsRepository(private val context: Context) { val PORT = intPreferencesKey("port") val PASSWORD = stringPreferencesKey("password") val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode") + val VOLUME_STEP = intPreferencesKey("volume_step") + val GRID_COLUMNS = intPreferencesKey("grid_columns") } /** How the albums library is laid out; emits on every change, defaulted when unset. */ @@ -36,6 +38,36 @@ class SettingsRepository(private val context: Context) { context.dataStore.edit { prefs -> prefs[Keys.ALBUM_VIEW_MODE] = mode.name } } + /** + * How many percent each hardware volume-key press moves the server volume; emits on every + * change, clamped into range and defaulted when unset. + */ + val volumeStep: Flow = + context.dataStore.data.map { prefs -> + (prefs[Keys.VOLUME_STEP] ?: DEFAULT_VOLUME_STEP).coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP) + } + + suspend fun setVolumeStep(step: Int) { + context.dataStore.edit { prefs -> + prefs[Keys.VOLUME_STEP] = step.coerceIn(MIN_VOLUME_STEP, MAX_VOLUME_STEP) + } + } + + /** + * How many columns the albums grid layout uses; emits on every change, clamped into range and + * defaulted when unset. + */ + val gridColumns: Flow = + context.dataStore.data.map { prefs -> + (prefs[Keys.GRID_COLUMNS] ?: DEFAULT_GRID_COLUMNS).coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS) + } + + suspend fun setGridColumns(columns: Int) { + context.dataStore.edit { prefs -> + prefs[Keys.GRID_COLUMNS] = columns.coerceIn(MIN_GRID_COLUMNS, MAX_GRID_COLUMNS) + } + } + /** * The saved settings, or `null` if the user has never connected (no host persisted yet). * Callers use the null case to show first-run UI / decide whether to auto-connect. @@ -70,4 +102,16 @@ class SettingsRepository(private val context: Context) { suspend fun clear() { context.dataStore.edit { it.clear() } } + + companion object { + /** Volume-step bounds and default (percent per hardware volume-key press). */ + const val DEFAULT_VOLUME_STEP = 1 + const val MIN_VOLUME_STEP = 1 + const val MAX_VOLUME_STEP = 10 + + /** Albums-grid column-count bounds and default. */ + const val DEFAULT_GRID_COLUMNS = 2 + const val MIN_GRID_COLUMNS = 1 + const val MAX_GRID_COLUMNS = 4 + } } diff --git a/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt index 1bb3d49..bdf8590 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/MpdClient.kt @@ -129,12 +129,15 @@ class MpdClient( /** Tear everything down; returns to [MpdConnectionState.Disconnected]. */ suspend fun disconnect() { shuttingDown = true - stopBackgroundJobs() - // Closing the idle socket unblocks the loop's parked `idle` read. + // Close the sockets *before* joining the loops. The idle loop is parked in a blocking + // socket read that coroutine cancellation cannot interrupt, so closing the socket is what + // unblocks it. If we joined first (as this used to), disconnect() would hang until the + // server happened to push an idle event — which on a quiet server can be a very long time. withContext(ioDispatcher) { idleConn?.close() commandConn?.close() } + stopBackgroundJobs() closeArtConnections() idleConn = null commandConn = null @@ -237,6 +240,21 @@ class MpdClient( /** Trigger a full re-read of the database, including files with an unchanged mtime. */ suspend fun rescanDatabase() = run(MpdCommands.rescan()) + /** + * Replace the queue with [uris] (in the given order) and start playing the first. Used when the + * user taps a track to play an album from that track onward. No-op on an empty list. + */ + suspend fun playTracks(uris: List) { + if (uris.isEmpty()) return + withCommand { conn -> + conn.executeList( + MpdCommands.clear(), + *uris.map { MpdCommands.add(it) }.toTypedArray(), + MpdCommands.play(), + ) + } + } + /** Replace the queue with an album and start playing it. */ suspend fun playAlbum( album: String, @@ -364,8 +382,13 @@ class MpdClient( // Cover fetches run on their own small pool of connections rather than the // command connection, so (a) many covers load in parallel while scrolling and // (b) art never blocks play/pause/volume. Connections are opened lazily, up - // to ART_POOL_SIZE (bounded by the semaphore), reused when idle, and dropped - // on any transport error (a stale/timed-out one just gets reopened). + // to ART_POOL_SIZE (bounded by the semaphore), and reused when idle. + // + // Unlike the command connection, these carry no keepalive, so MPD reaps them + // after its connection_timeout (idle art is fetched rarely). A borrowed pooled + // connection may therefore be dead; reusing it throws a transport error. When + // that happens we discard it and retry once on a freshly opened connection, so + // a stale socket can't blank a cover until the app is restarted. private val artSemaphore = Semaphore(ART_POOL_SIZE) private val artPoolLock = Any() @@ -373,33 +396,54 @@ class MpdClient( private suspend fun withArtConnection(block: (MpdConnection) -> T): T = artSemaphore.withPermit { - val conn = borrowArtConnection() - try { - val result = withContext(ioDispatcher) { block(conn) } - returnArtConnection(conn) - result - } catch (e: MpdAckException) { - returnArtConnection(conn) // command refused, but connection is healthy - throw e - } catch (e: Throwable) { - runCatching { conn.close() } // discard a broken connection - throw e + // First try a pooled connection, if any. A transport failure here means + // it went stale — discard it (runArt already closed it) and fall through + // to a fresh one. A server ACK is a real result and is not retried. + borrowPooledArtConnection()?.let { pooled -> + try { + return@withPermit runArt(pooled, block) + } catch (e: MpdAckException) { + throw e + } catch (e: IOException) { + // stale pooled connection; retry on a fresh one below + } } + runArt(openArtConnection(), block) } - private suspend fun borrowArtConnection(): MpdConnection { + /** + * Run [block] on [conn], returning it to the pool on success (or a clean `ACK`, which leaves the + * connection healthy) and closing it on any transport failure. + */ + private suspend fun runArt( + conn: MpdConnection, + block: (MpdConnection) -> T, + ): T = + try { + val result = withContext(ioDispatcher) { block(conn) } + returnArtConnection(conn) + result + } catch (e: MpdAckException) { + returnArtConnection(conn) // command refused, but connection is healthy + throw e + } catch (e: Throwable) { + runCatching { conn.close() } // discard a broken connection + throw e + } + + /** Pop an idle pooled art connection, or null if the pool is empty. */ + private fun borrowPooledArtConnection(): MpdConnection? = synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() } - ?.let { - return it - } - return withContext(ioDispatcher) { + + /** Open, authenticate, and prime a brand-new art connection. */ + private suspend fun openArtConnection(): MpdConnection = + withContext(ioDispatcher) { val h = host ?: throw MpdConnectionException("not connected") val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS) password?.let { conn.execute(MpdCommands.password(it)) } runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) } conn } - } private fun returnArtConnection(conn: MpdConnection) { val kept = @@ -483,8 +527,11 @@ class MpdClient( } private suspend fun reconnectLoop() { - stopBackgroundJobs() + // Close first, then join — same reason as disconnect(): closing the sockets unblocks the + // idle loop's parked blocking read so stopBackgroundJobs() doesn't wait on it. (The idle + // loop's own error handler no-ops here because a reconnect is already in flight.) closeConnectionsQuietly() + stopBackgroundJobs() _connectionState.value = MpdConnectionState.Connecting var attempt = 0 while (scope.isActive && !shuttingDown) { diff --git a/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdSong.kt b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdSong.kt index 36ee344..2d1bb4a 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdSong.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/mpd/model/MpdSong.kt @@ -18,6 +18,8 @@ data class MpdSong( val disc: String?, val date: String?, val genre: String?, + val lastModified: String?, // "Last-Modified" ISO timestamp; used to cache-bust cover art + val format: String?, // "Format" audio format "samplerate:bits:channels" (from the DB scan) val duration: Double?, // from "duration" (fractional) or legacy "Time" val pos: Int?, // queue position, present in queue listings val id: Int?, // stable queue id, present in queue listings @@ -39,6 +41,8 @@ data class MpdSong( disc = values["Disc"], date = values["Date"], genre = values["Genre"], + lastModified = values["Last-Modified"], + format = values["Format"], duration = values["duration"]?.toDoubleOrNull() ?: values["Time"]?.toDoubleOrNull(), pos = values["Pos"]?.toIntOrNull(), id = values["Id"]?.toIntOrNull(), diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/ArtImageLoader.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/ArtImageLoader.kt index be0b41c..5ae8ff2 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/ArtImageLoader.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/ArtImageLoader.kt @@ -41,10 +41,16 @@ object ArtImageLoader { } .build() - /** Stable cache key per art request; shared by the keyer and the disk cache. */ + /** + * Stable cache key per art request; shared by the keyer and the disk cache. For [SongArt] the + * optional [SongArt.version] is appended so a re-tagged file (new mtime) lands on a fresh key + * and refetches; album keys stay version-free so an album's tracks share one entry. + */ private fun cacheKey(data: MpdArtData): String = when (data) { - is SongArt -> "song:${data.uri}" + is SongArt -> + if (data.version.isNullOrEmpty()) "song:${data.uri}" + else "song:${data.uri}@${data.version}" is AlbumArt -> "album:${data.albumArtist.orEmpty()}/${data.album}" } diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdArt.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdArt.kt index ba32b34..49eced4 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdArt.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdArt.kt @@ -7,10 +7,24 @@ package ca.ksamad.encore.playback */ sealed interface MpdArtData -/** Cover art for a specific song URI (used for now-playing and the notification). */ -data class SongArt(val uri: String) : MpdArtData +/** + * Cover art for a specific song URI (used for now-playing and the notification). + * + * [version] is an opaque cache-bust token — typically the file's `Last-Modified` timestamp. It has + * no effect on how the art is fetched (only [uri] matters for that); it only participates in the + * cache key, so when a track is re-tagged/re-imported (its mtime changes) the new art is fetched + * automatically instead of serving a stale cached copy. + */ +data class SongArt( + val uri: String, + val version: String? = null, +) : MpdArtData -/** Cover art for an album (a representative track is resolved server-side). */ +/** + * Cover art for an album (a representative track is resolved server-side). Deliberately has no + * per-track version: every track of an album must resolve to one cache entry (see the queue), so + * album art is refreshed via the manual "Refresh artwork" action rather than mtime. + */ data class AlbumArt( val album: String, val albumArtist: String?, diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt index e8a5392..f9e5670 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt @@ -9,6 +9,7 @@ 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 coil3.SingletonImageLoader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -56,6 +57,30 @@ class MpdConnectionManager(context: Context) { scope.launch { settingsRepo.setAlbumViewMode(mode) } } + /** Persisted volume-key step (percent per press). */ + val volumeStep: StateFlow = + settingsRepo.volumeStep.stateIn( + scope, + SharingStarted.Eagerly, + SettingsRepository.DEFAULT_VOLUME_STEP, + ) + + fun setVolumeStep(step: Int) { + scope.launch { settingsRepo.setVolumeStep(step) } + } + + /** Persisted albums-grid column count. */ + val gridColumns: StateFlow = + settingsRepo.gridColumns.stateIn( + scope, + SharingStarted.Eagerly, + SettingsRepository.DEFAULT_GRID_COLUMNS, + ) + + fun setGridColumns(columns: Int) { + scope.launch { settingsRepo.setGridColumns(columns) } + } + // True until the startup auto-connect decision has been made. private val _bootstrapping = MutableStateFlow(true) val bootstrapping: StateFlow = _bootstrapping @@ -176,6 +201,9 @@ class MpdConnectionManager(context: Context) { albumArtist: String?, ) = fire { playAlbumNext(album, albumArtist) } + /** Replace the queue with [uris] (in order) and start playing the first. */ + fun playTracks(uris: List) = fire { playTracks(uris) } + /** Append a single track to the end of the queue. */ fun queueTrack(uri: String) = fire { queueTrack(uri) } @@ -218,6 +246,19 @@ class MpdConnectionManager(context: Context) { suspend fun loadQueue(): List = runCatching { client.queue() }.getOrDefault(emptyList()) + /** + * Drop every cached cover (memory + disk) so the next load refetches from the server. Backs the + * "Refresh artwork" action — for art that isn't mtime-keyed (album covers, and folder cover + * files MPD serves without touching the track's mtime), this is the way to pick up changes made + * on the server. Album-keyed art then reloads as each screen is reopened. + */ + fun refreshArtwork() { + val loader = SingletonImageLoader.get(appContext) + loader.memoryCache?.clear() + // Disk clear touches the filesystem — keep it off the main thread. + scope.launch(Dispatchers.IO) { loader.diskCache?.clear() } + } + /** Fetch cover-art bytes for Coil (null on miss/failure). */ suspend fun fetchArt(data: MpdArtData): ByteArray? = runCatching { @@ -228,19 +269,11 @@ class MpdConnectionManager(context: Context) { } .getOrNull() - /** - * True when we own the volume: connected to a server that exposes a mixer. Used to decide - * whether hardware volume keys drive the *server* (silently, in-app) or fall through to the - * device's own local volume. - */ - val isControllingVolume: Boolean - get() = - connectionState.value is MpdConnectionState.Connected && status.value?.volume != null - - /** Relative volume change (from a hardware key / VolumeProvider), accumulating. */ + /** Relative volume change (from the media session's remote VolumeProvider), accumulating. */ fun nudgeVolume(up: Boolean) { val base = pendingVolume ?: status.value?.volume ?: return - val next = (base + if (up) VOLUME_STEP else -VOLUME_STEP).coerceIn(0, 100) + val step = volumeStep.value + val next = (base + if (up) step else -step).coerceIn(0, 100) pendingVolume = next setVolume(next) } @@ -250,7 +283,6 @@ class MpdConnectionManager(context: Context) { } private companion object { - const val VOLUME_STEP = 5 const val VOLUME_THROTTLE_MS = 120L } } diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/PlaybackService.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/PlaybackService.kt index 4ea09fb..0d1fb05 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/PlaybackService.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/PlaybackService.kt @@ -145,7 +145,8 @@ class PlaybackService : Service() { .distinctUntilChanged() .collectLatest { uri -> artUri = uri - artBitmap = if (uri != null) loadArtBitmap(uri) else null + val version = manager.currentSong.value?.takeIf { it.uri == uri }?.lastModified + artBitmap = if (uri != null) loadArtBitmap(uri, version) else null if (artUri == uri) { session.setMetadata( buildMetadata(manager.currentSong.value, manager.status.value) @@ -164,10 +165,15 @@ class PlaybackService : Service() { } } - private suspend fun loadArtBitmap(uri: String): Bitmap? { + private suspend fun loadArtBitmap( + uri: String, + version: String?, + ): Bitmap? { val result = SingletonImageLoader.get(applicationContext) - .execute(ImageRequest.Builder(applicationContext).data(SongArt(uri)).build()) + .execute( + ImageRequest.Builder(applicationContext).data(SongArt(uri, version)).build() + ) return (result as? SuccessResult)?.image?.toBitmap() } diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/VolumeKeyDispatcher.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/VolumeKeyDispatcher.kt deleted file mode 100644 index 8890450..0000000 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/VolumeKeyDispatcher.kt +++ /dev/null @@ -1,39 +0,0 @@ -package ca.ksamad.encore.playback - -import android.view.KeyEvent - -/** - * Encapsulates hardware volume-key handling for the foreground Activity. - * - * While the app is focused, we want the volume keys to drive the *server* volume **silently** — - * without the system's volume slider popping up (the app already shows its own). The trick is to - * fully consume the key event in the Activity so it never reaches the OS volume handling that draws - * that slider. - * - * When the app is backgrounded the Activity isn't in the dispatch path at all, so the - * `MediaSession`'s `VolumeProvider` handles the keys instead — there the OS remote-volume UI - * showing up is the expected, cast-style behaviour. - * - * Only volume keys we actually act on are consumed: when we're not controlling the server (e.g. the - * connect screen), the event passes through so the device adjusts its own local volume normally. - */ -class VolumeKeyDispatcher(private val manager: MpdConnectionManager) { - /** - * Offer [event] to the volume handler. Returns `true` if it was a volume key we handled and - * consumed (caller should then *not* pass it on), `false` to let normal dispatch continue. - */ - fun dispatch(event: KeyEvent): Boolean { - val up = - when (event.keyCode) { - KeyEvent.KEYCODE_VOLUME_UP -> true - KeyEvent.KEYCODE_VOLUME_DOWN -> false - else -> return false - } - if (!manager.isControllingVolume) return false - - // Nudge on each key-down (auto-repeats included, so holding keeps - // changing); swallow the key-up too so no system UI flashes. - if (event.action == KeyEvent.ACTION_DOWN) manager.nudgeVolume(up) - return true - } -} diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt index 21e8263..7bba7a2 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumDetailScreen.kt @@ -1,8 +1,10 @@ package ca.ksamad.encore.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -48,9 +50,9 @@ import kotlinx.coroutines.launch /** * One album, in detail: its cover, whole-album Play / Add-to-queue actions, and the track list. - * Tapping Play replaces the queue and starts the album (leaving this screen); swiping a track - * queues it (right) or plays it next (left), with the same semantics as swiping an album in the - * library. + * Tapping Play replaces the queue and starts the album (leaving this screen); tapping a track does + * the same but starts the album from that track onward. Swiping a track queues it (right) or plays + * it next (left), with the same semantics as swiping an album in the library. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,6 +68,10 @@ fun AlbumDetailScreen( value = vm.loadAlbumTracks(album.name, album.albumArtist) } + // The album's audio format, taken from the tracks we already loaded (MPD reports a per-song + // `Format` in the track listing), so no extra network call. Null until tracks arrive. + val albumFormat = remember(tracks) { tracks?.let(::representativeFormat) } + val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -91,6 +97,7 @@ fun AlbumDetailScreen( item { AlbumDetailHeader( album = album, + format = albumFormat, onPlay = { vm.playAlbum(album.name, album.albumArtist) onPlay() @@ -102,11 +109,21 @@ fun AlbumDetailScreen( ) } - // One track row, wired to its swipe actions. Reused whether the list is - // flat or split into disc groups. + // One track row, wired to its tap + swipe actions. Reused whether the list + // is flat or split into disc groups. val trackItem: @Composable (MpdSong) -> Unit = { track -> TrackRow( track = track, + onPlay = { + // Play the album from this track on: replace the queue with the + // tapped track and everything after it (the list is already in + // disc/track order), then start playing. Stay on the album page — + // just flash a confirmation rather than jumping to now-playing. + val loaded = tracks ?: return@TrackRow + val fromHere = loaded.dropWhile { it.uri != track.uri }.map { it.uri } + vm.playTracks(fromHere) + flash("Playing “${track.title ?: track.uri}”") + }, onQueue = { vm.queueTrack(track.uri) flash("Added “${track.title ?: track.uri}” to the queue") @@ -163,10 +180,11 @@ fun AlbumDetailScreen( } } -/** Cover, title/artist, and the two whole-album action buttons. */ +/** Cover, title/artist, audio-format pills, and the two whole-album action buttons. */ @Composable private fun AlbumDetailHeader( album: MpdAlbum, + format: String?, onPlay: () -> Unit, onQueue: () -> Unit, ) { @@ -177,7 +195,7 @@ private fun AlbumDetailHeader( ArtImage( model = AlbumArt(album.name, album.albumArtist), iconSize = 72.dp, - modifier = Modifier.size(220.dp).clip(RoundedCornerShape(12.dp)), + modifier = Modifier.fillMaxWidth(0.85f).aspectRatio(1f).clip(RoundedCornerShape(12.dp)), ) Spacer(Modifier.size(16.dp)) Text( @@ -197,6 +215,7 @@ private fun AlbumDetailHeader( overflow = TextOverflow.Ellipsis, ) } + AudioFormatPills(pills = audioFormatPills(format), modifier = Modifier.padding(top = 10.dp)) Spacer(Modifier.size(16.dp)) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -215,18 +234,21 @@ 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 leading slot shows the track number (all - * tracks share the album's cover, so a per-track thumbnail would be redundant here). + * A single track row. Tapping plays the album from this track onward (replacing the queue); swiping + * right queues just this track and swiping left plays it next — the same gestures 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( track: MpdSong, + onPlay: () -> Unit, onQueue: () -> Unit, onPlayNext: () -> Unit, ) { QueueSwipeRow(onAddToQueue = onQueue, onPlayNext = onPlayNext) { ListItem( + modifier = Modifier.clickable(onClick = onPlay), leadingContent = { Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { Text( @@ -266,3 +288,16 @@ private fun trackNumberOf(track: MpdSong): Int? = /** 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 + +/** + * The album's representative audio format: the most common per-track `Format` + * (`samplerate:bits:channels`) among the loaded tracks. Null when no track reports one. Using the + * mode keeps a stray transcoded/hidden track from misrepresenting the album. + */ +private fun representativeFormat(tracks: List): String? = + tracks + .mapNotNull { it.format } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt index 713877f..6e799b4 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items @@ -69,10 +71,15 @@ import kotlinx.coroutines.launch @Composable fun AlbumsScreen( vm: PlayerViewModel, + query: String, + onQueryChange: (String) -> Unit, + listState: LazyListState, + gridState: LazyGridState, onBack: () -> Unit, onOpenAlbum: (MpdAlbum) -> Unit, ) { val viewMode by vm.albumViewMode.collectAsStateWithLifecycle() + val gridColumns by vm.gridColumns.collectAsStateWithLifecycle() // null = still loading. val albums by @@ -84,9 +91,10 @@ fun AlbumsScreen( ) } - // Client-side search over the already-loaded index (album title + album artist). - var searching by remember { mutableStateOf(false) } - var query by remember { mutableStateOf("") } + // The search text is hoisted (owned by EncoreApp) so it survives drilling into + // an album and coming back. Whether the search bar is *open* is local, but it + // starts open whenever there's a restored query so returning re-shows the search. + var searching by remember { mutableStateOf(query.isNotEmpty()) } // Transient confirmation for the swipe actions (add-to-queue / play-next). val snackbarHostState = remember { SnackbarHostState() } @@ -128,7 +136,7 @@ fun AlbumsScreen( TopAppBar( title = { if (searching) { - AlbumSearchField(query = query, onQueryChange = { query = it }) + AlbumSearchField(query = query, onQueryChange = onQueryChange) } else { Text(filtered?.let { "Albums (${it.size})" } ?: "Albums") } @@ -138,7 +146,7 @@ fun AlbumsScreen( onClick = { if (searching) { searching = false - query = "" + onQueryChange("") } else { onBack() } @@ -149,7 +157,7 @@ fun AlbumsScreen( }, actions = { if (searching) { - IconButton(onClick = { query = "" }, enabled = query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }, enabled = query.isNotEmpty()) { Icon(Icons.Filled.Clear, contentDescription = "Clear search") } } else if (current != null) { @@ -187,7 +195,8 @@ fun AlbumsScreen( viewMode == AlbumViewMode.Grid -> { LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(gridColumns), + state = gridState, modifier = Modifier.padding(innerPadding), contentPadding = PaddingValues(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -200,7 +209,7 @@ fun AlbumsScreen( } else -> { - LazyColumn(modifier = Modifier.padding(innerPadding)) { + LazyColumn(state = listState, modifier = Modifier.padding(innerPadding)) { items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album -> SwipeableAlbumRow( album = album, @@ -291,8 +300,10 @@ private fun AlbumGridCell( } /** - * Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard; - * the surrounding [TopAppBar] handles clearing/closing. + * Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard + * only on a fresh (empty) open — when it reappears already populated (e.g. restored after returning + * from an album) it shows the query and results without stealing focus. The surrounding [TopAppBar] + * handles clearing/closing. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -321,5 +332,6 @@ private fun AlbumSearchField( ), ) - LaunchedEffect(Unit) { focusRequester.requestFocus() } + // Only grab focus for a fresh search; a restored (non-empty) query shouldn't pop the keyboard. + LaunchedEffect(Unit) { if (query.isEmpty()) focusRequester.requestFocus() } } diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/AudioFormatPills.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AudioFormatPills.kt new file mode 100644 index 0000000..5365149 --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/AudioFormatPills.kt @@ -0,0 +1,82 @@ +package ca.ksamad.encore.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +/** + * A row of little rounded pills describing an audio format — sample rate, bit depth, and channel + * layout (e.g. "44.1 kHz", "16-bit", "Stereo"). Shared by now-playing (the live stream) and album + * detail (an album's stored format). Renders nothing when [pills] is empty. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AudioFormatPills( + pills: List, + modifier: Modifier = Modifier, +) { + if (pills.isEmpty()) return + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + pills.forEach { pill -> + Text( + text = pill, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = + Modifier.clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) + } + } +} + +/** + * Parse an MPD audio-format string, `samplerate:bits:channels` (e.g. `"44100:16:2"`), into pill + * labels. The middle token can be `f` (float) or `dsd` instead of a bit depth. Only well-formed + * parts appear; a null/garbage input yields an empty list. + */ +fun audioFormatPills(format: String?): List { + val parts = format?.split(":")?.takeIf { it.size >= 3 } ?: return emptyList() + return buildList { + parts[0].toIntOrNull()?.let { add(formatSampleRate(it)) } + formatSampleFormat(parts[1])?.let { add(it) } + formatChannels(parts[2])?.let { add(it) } + } +} + +private fun formatSampleRate(hz: Int): String { + val khz = hz / 1000.0 + val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz) + return "$value kHz" +} + +private fun formatSampleFormat(token: String): String? = + when (token) { + "f" -> "Float" + "dsd" -> "DSD" + else -> token.toIntOrNull()?.let { "$it-bit" } + } + +private fun formatChannels(token: String): String? = + when (token.toIntOrNull()) { + null -> null + 1 -> "Mono" + 2 -> "Stereo" + else -> "$token ch" + } diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt index 1a51eb0..80ea2a1 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/EncoreApp.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -63,6 +65,14 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) { var overlay by rememberSaveable { mutableStateOf(PlayerOverlay.None) } // When set (within the Albums overlay), the album detail screen is shown. var detailAlbum by rememberSaveable(stateSaver = AlbumSaver) { mutableStateOf(null) } + // Owned here (not in AlbumsScreen) so the search text survives drilling into an + // album and back — AlbumsScreen leaves composition while the detail is shown. + var albumSearch by rememberSaveable { mutableStateOf("") } + // Same reasoning for the library's scroll position (one per layout), so backing + // out of an album returns you to where you were. rememberLazy*State is itself + // saveable, so these also survive rotation/process death. + val albumListState = rememberLazyListState() + val albumGridState = rememberLazyGridState() // Overlays only make sense over the player; leaving it (e.g. after a // reset/disconnect) drops us back to the normal screen flow. @@ -73,6 +83,13 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) { } } + // Forget the album search once we leave the library entirely (to the player or + // another overlay). The detail screen keeps overlay == Albums, so browsing into + // an album and back preserves it. + LaunchedEffect(overlay) { + if (overlay != PlayerOverlay.Albums) albumSearch = "" + } + when (val s = screen) { is AppScreen.Loading -> { LoadingScreen() @@ -115,6 +132,10 @@ fun EncoreApp(vm: PlayerViewModel = viewModel()) { } else { AlbumsScreen( vm, + query = albumSearch, + onQueryChange = { albumSearch = it }, + listState = albumListState, + gridState = albumGridState, onBack = { overlay = PlayerOverlay.None }, onOpenAlbum = { detailAlbum = it }, ) diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt index 260ad5c..f8f340e 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/NowPlayingScreen.kt @@ -1,10 +1,9 @@ package ca.ksamad.encore.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,10 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Cast @@ -27,8 +28,8 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -49,11 +50,22 @@ 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.encore.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdStatus import ca.ksamad.encore.mpd.model.PlayerState import ca.ksamad.encore.playback.SongArt import kotlinx.coroutines.delay +/** Largest the flexible album art is allowed to grow, so it doesn't sprawl on tablets/foldables. */ +private val ART_MAX_SIZE = 480.dp + +/** + * Minimum usable height at which we trust the whole screen fits without scrolling (and switch to the + * flexible, art-fills-the-slack layout). Below this — landscape, small phones — we scroll instead so + * the lower controls never get clipped. + */ +private val FLEX_MIN_HEIGHT = 740.dp + /** * The now-playing screen: current track, a live seek bar, transport controls, volume, and the * repeat/random toggles. Everything reads from the [PlayerViewModel] flows, so it updates whenever @@ -68,152 +80,214 @@ fun NowPlayingScreen( ) { val status by vm.status.collectAsStateWithLifecycle() val song by vm.currentSong.collectAsStateWithLifecycle() - val serverHost by vm.serverHost.collectAsStateWithLifecycle() + var showDisconnectConfirm by remember { mutableStateOf(false) } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // --- Top bar: library + settings ------------------------------------ - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - IconButton(onClick = onOpenQueue) { - Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue") + // On a tall enough screen, lay everything out without scrolling and let the album art + // flex into the leftover vertical space (capped at ART_MAX_SIZE) — bigger displays show + // bigger art, smaller ones just shrink it. On a short viewport (landscape, small phones) + // fall back to a scrolling column with a fixed art size so the lower controls stay reachable. + val scrollState = rememberScrollState() + BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) { + val flexible = maxHeight >= FLEX_MIN_HEIGHT + // top = 0: the icon buttons carry their own internal padding, and systemBarsPadding + // already clears the status bar — any extra top padding just pushes them down. + val columnModifier = + if (flexible) { + Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, bottom = 12.dp) + } else { + Modifier.fillMaxSize() + .verticalScroll(scrollState) + .padding(start = 24.dp, end = 24.dp, bottom = 24.dp) } - IconButton(onClick = onOpenLibrary) { - Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums") + + Column(modifier = columnModifier, horizontalAlignment = Alignment.CenterHorizontally) { + // --- Top bar: disconnect (left) + queue/library/settings (right) ---- + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { showDisconnectConfirm = true }) { + Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect") + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onOpenQueue) { + Icon(Icons.AutoMirrored.Filled.QueueMusic, contentDescription = "Queue") + } + IconButton(onClick = onOpenLibrary) { + Icon(Icons.Filled.LibraryMusic, contentDescription = "Albums") + } + IconButton(onClick = onOpenSettings) { + Icon(Icons.Filled.Settings, contentDescription = "Settings") + } } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Filled.Settings, contentDescription = "Settings") - } - } - // --- Album art ------------------------------------------------------- - ArtImage( - model = song?.uri?.let { SongArt(it) }, - iconSize = 96.dp, - modifier = - Modifier.padding(vertical = 16.dp).size(240.dp).clip(RoundedCornerShape(16.dp)), - ) - - // --- Track metadata -------------------------------------------------- - Text( - text = song?.title ?: song?.uri ?: "Nothing playing", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = song?.artist ?: "—", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = song?.album ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - // 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)) - - SeekBar(status = status, onSeek = { vm.seekTo(it) }) - - Spacer(Modifier.height(16.dp)) - - // --- Transport ------------------------------------------------------- - val playing = status?.state == PlayerState.PLAY - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) { - Icon( - Icons.Filled.SkipPrevious, - contentDescription = "Previous", - modifier = Modifier.size(36.dp), - ) - } - FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) { - Icon( - if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (playing) "Pause" else "Play", - modifier = Modifier.size(40.dp), - ) - } - IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) { - Icon( - Icons.Filled.SkipNext, - contentDescription = "Next", - modifier = Modifier.size(36.dp), - ) - } - } - - Spacer(Modifier.height(24.dp)) - - // CastIndicator(host = serverHost) - VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) }) - - Spacer(Modifier.height(16.dp)) - - // --- Playback options ------------------------------------------------ - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - FilterChip( - selected = status?.repeat == true, - onClick = { vm.setRepeat(status?.repeat != true) }, - label = { Text("Repeat") }, - leadingIcon = { - Icon( - Icons.Filled.Repeat, - contentDescription = null, - modifier = Modifier.size(18.dp), + // --- Album art --------------------------------------------------- + val artModel = song?.let { SongArt(it.uri, it.lastModified) } + if (flexible) { + // Fill the leftover height, kept square and capped by width/height/max. + BoxWithConstraints( + modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + val side = minOf(maxWidth, maxHeight, ART_MAX_SIZE) + ArtImage( + model = artModel, + iconSize = 96.dp, + modifier = Modifier.size(side).clip(RoundedCornerShape(16.dp)), ) - }, - ) - FilterChip( - selected = status?.random == true, - onClick = { vm.setRandom(status?.random != true) }, - label = { Text("Shuffle") }, - leadingIcon = { - Icon( - Icons.Filled.Shuffle, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - }, - ) - } + } + } else { + ArtImage( + model = artModel, + iconSize = 96.dp, + modifier = + Modifier.padding(vertical = 16.dp) + .fillMaxWidth(0.9f) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)), + ) + } - Spacer(Modifier.height(24.dp)) - - TextButton(onClick = vm::disconnect) { - Text("Disconnect") + PlaybackDetails(vm = vm, status = status, song = song) } } + + if (showDisconnectConfirm) { + AlertDialog( + onDismissRequest = { showDisconnectConfirm = false }, + title = { Text("Disconnect?") }, + text = { Text("Disconnect from the server and return to the connect screen.") }, + confirmButton = { + TextButton( + onClick = { + showDisconnectConfirm = false + vm.disconnect() + } + ) { + Text("Disconnect") + } + }, + dismissButton = { + TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") } + }, + ) + } +} + +/** + * Everything below the album art: track metadata, the seek bar, transport, volume, and the option + * chips. Emitted straight into the caller's centered [Column] so it's shared by both the flexible + * (no-scroll) and scrolling layouts. + */ +@Composable +private fun PlaybackDetails( + vm: PlayerViewModel, + status: MpdStatus?, + song: MpdSong?, +) { + // --- Track metadata -------------------------------------------------- + Text( + text = song?.title ?: song?.uri ?: "Nothing playing", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = song?.artist ?: "—", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = song?.album ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // 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(24.dp)) + + SeekBar(status = status, onSeek = { vm.seekTo(it) }) + + Spacer(Modifier.height(16.dp)) + + // --- Transport (repeat · prev · play/pause · next · shuffle) --------- + // Repeat and shuffle are icon-only toggles — their icons are well-known, so no + // label is needed. They tint to the primary colour when on, muted when off. + val playing = status?.state == PlayerState.PLAY + val repeatOn = status?.repeat == true + val shuffleOn = status?.random == true + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + IconButton(onClick = { vm.setRepeat(!repeatOn) }, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Filled.Repeat, + contentDescription = if (repeatOn) "Repeat on" else "Repeat off", + tint = + if (repeatOn) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) + } + IconButton(onClick = vm::previous, modifier = Modifier.size(56.dp)) { + Icon( + Icons.Filled.SkipPrevious, + contentDescription = "Previous", + modifier = Modifier.size(36.dp), + ) + } + FilledIconButton(onClick = vm::togglePlayPause, modifier = Modifier.size(72.dp)) { + Icon( + if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (playing) "Pause" else "Play", + modifier = Modifier.size(40.dp), + ) + } + IconButton(onClick = vm::next, modifier = Modifier.size(56.dp)) { + Icon( + Icons.Filled.SkipNext, + contentDescription = "Next", + modifier = Modifier.size(36.dp), + ) + } + IconButton(onClick = { vm.setRandom(!shuffleOn) }, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Filled.Shuffle, + contentDescription = if (shuffleOn) "Shuffle on" else "Shuffle off", + tint = + if (shuffleOn) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) + } + } + + Spacer(Modifier.height(20.dp)) + + VolumeControl(volume = status?.volume, onSetVolume = { vm.setVolume(it) }) } /** @@ -268,74 +342,18 @@ private fun SeekBar( * (live) bitrate — derived from MPD's `audio`/`bitrate` status fields. Renders nothing when there's * no format info (e.g. stopped). */ -@OptIn(ExperimentalLayoutApi::class) @Composable private fun AudioPropertyPills(status: MpdStatus?) { - val pills = remember(status?.audio, status?.bitrate) { audioPropertyPills(status) } - if (pills.isEmpty()) return - - FlowRow( - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - pills.forEach { pill -> - Text( - text = pill, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = - Modifier.clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 10.dp, vertical = 4.dp), - ) + val pills = + remember(status?.audio, status?.bitrate) { + buildList { + addAll(audioFormatPills(status?.audio)) + status?.bitrate?.takeIf { it > 0 }?.let { add("$it kbps") } + } } - } + AudioFormatPills(pills = pills, modifier = Modifier.padding(top = 10.dp)) } -/** - * Build the audio-format pill labels from a status snapshot. MPD's `audio` field is - * `samplerate:bits:channels` (e.g. `"44100:16:2"`), where the middle token can be `f` (float) or - * `dsd` rather than a bit depth. Only well-formed parts appear. - */ -private fun audioPropertyPills(status: MpdStatus?): List { - status ?: return emptyList() - val pills = mutableListOf() - - status.audio - ?.split(":") - ?.takeIf { it.size >= 3 } - ?.let { parts -> - parts[0].toIntOrNull()?.let { pills.add(formatSampleRate(it)) } - formatSampleFormat(parts[1])?.let { pills.add(it) } - formatChannels(parts[2])?.let { pills.add(it) } - } - status.bitrate?.takeIf { it > 0 }?.let { pills.add("$it kbps") } - - return pills -} - -private fun formatSampleRate(hz: Int): String { - val khz = hz / 1000.0 - val value = if (khz % 1.0 == 0.0) khz.toInt().toString() else "%.1f".format(khz) - return "$value kHz" -} - -private fun formatSampleFormat(token: String): String? = - when (token) { - "f" -> "Float" - "dsd" -> "DSD" - else -> token.toIntOrNull()?.let { "$it-bit" } - } - -private fun formatChannels(token: String): String? = - when (token.toIntOrNull()) { - null -> null - 1 -> "Mono" - 2 -> "Stereo" - else -> "$token ch" - } - /** "Casting" affordance: signals that the volume below controls the server, not the device. */ @Composable private fun CastIndicator(host: String?) { diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt index 5d54acc..66bd881 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt @@ -41,6 +41,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) val serverHost = manager.serverHost val settings = manager.settings val albumViewMode = manager.albumViewMode + val volumeStep = manager.volumeStep + val gridColumns = manager.gridColumns val screen: StateFlow = combine( @@ -91,6 +93,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode) + fun setVolumeStep(step: Int) = manager.setVolumeStep(step) + + fun setGridColumns(columns: Int) = manager.setGridColumns(columns) + fun playAlbum( album: String, albumArtist: String?, @@ -106,6 +112,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) albumArtist: String?, ) = manager.playAlbumNext(album, albumArtist) + fun playTracks(uris: List) = manager.playTracks(uris) + fun queueTrack(uri: String) = manager.queueTrack(uri) fun playTrackNext(uri: String) = manager.playTrackNext(uri) @@ -121,6 +129,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun rescanDatabase() = manager.rescanDatabase() + fun refreshArtwork() = manager.refreshArtwork() + fun playQueueItem(songId: Int) = manager.playQueueItem(songId) fun removeQueueItem(songId: Int) = manager.removeQueueItem(songId) diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt index d1995f7..6090c74 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/QueueScreen.kt @@ -50,6 +50,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import ca.ksamad.encore.mpd.model.MpdSong import ca.ksamad.encore.mpd.model.MpdStatus +import ca.ksamad.encore.playback.AlbumArt +import ca.ksamad.encore.playback.MpdArtData import ca.ksamad.encore.playback.SongArt import kotlinx.coroutines.launch @@ -220,6 +222,17 @@ fun QueueScreen( } } +/** + * Art model for a queue row. Keying by album — not the per-track URI — means every track of the + * same album resolves to one cache entry, so a 12-track album is fetched from the server once + * instead of twelve times, and reuses whatever the album library already cached. Falls back to the + * track's own art only for entries with no album tag (e.g. a loose file or a stream). + */ +private fun queueArt(song: MpdSong): MpdArtData = + song.album + ?.takeIf { it.isNotBlank() } + ?.let { AlbumArt(it, song.albumArtist) } ?: SongArt(song.uri, song.lastModified) + /** A single queue entry — art, title/artist, and a duration or now-playing badge. */ @Composable private fun QueueRow( @@ -237,7 +250,7 @@ private fun QueueRow( }, leadingContent = { ArtImage( - model = SongArt(song.uri), + model = queueArt(song), iconSize = 20.dp, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)), ) diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt index 7a223ed..8bd19bc 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt @@ -3,17 +3,22 @@ package ca.ksamad.encore.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -43,10 +48,13 @@ 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 +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import ca.ksamad.encore.data.AlbumViewMode +import ca.ksamad.encore.data.SettingsRepository import ca.ksamad.encore.mpd.model.MpdStatistics import java.text.SimpleDateFormat import java.util.Date @@ -141,6 +149,35 @@ fun SettingsScreen( }, ) + // How far the hardware volume keys move the server volume per press. + val volumeStep by vm.volumeStep.collectAsStateWithLifecycle() + ListItem( + headlineContent = { Text("Volume step") }, + supportingContent = { Text("How much each volume-key press changes the volume") }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { vm.setVolumeStep(volumeStep - 1) }, + enabled = volumeStep > SettingsRepository.MIN_VOLUME_STEP, + ) { + Icon(Icons.Filled.Remove, contentDescription = "Decrease volume step") + } + Text( + "$volumeStep%", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.width(40.dp), + textAlign = TextAlign.Center, + ) + IconButton( + onClick = { vm.setVolumeStep(volumeStep + 1) }, + enabled = volumeStep < SettingsRepository.MAX_VOLUME_STEP, + ) { + Icon(Icons.Filled.Add, contentDescription = "Increase volume step") + } + } + }, + ) + Text( "Library", style = MaterialTheme.typography.titleSmall, @@ -176,6 +213,35 @@ fun SettingsScreen( }, ) + // Column count only matters for the grid layout, so only offer it there. + if (albumViewMode == AlbumViewMode.Grid) { + val gridColumns by vm.gridColumns.collectAsStateWithLifecycle() + val columnOptions = + SettingsRepository.MIN_GRID_COLUMNS..SettingsRepository.MAX_GRID_COLUMNS + ListItem( + headlineContent = { Text("Grid columns") }, + supportingContent = { Text("Number of albums per row") }, + trailingContent = { + SingleChoiceSegmentedButtonRow { + columnOptions.forEachIndexed { index, columns -> + SegmentedButton( + selected = gridColumns == columns, + onClick = { vm.setGridColumns(columns) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = columnOptions.count(), + ), + icon = {}, + ) { + Text(columns.toString()) + } + } + } + }, + ) + } + // Ask the server to rescan its music sources. Tap runs a normal (mtime-based) // update; the overflow offers a full rescan that also re-reads unchanged files. // While a scan runs, `status.updating` drives the "Refreshing…" state. @@ -219,6 +285,23 @@ fun SettingsScreen( }, ) + // Drop Encore's cached covers so corrected art on the server shows up. Most + // re-tagged/re-imported tracks refresh on their own (their mtime changes the + // art's cache key); this is for the rest — album covers and bare cover.jpg + // swaps that don't bump a track's mtime. + ListItem( + modifier = + Modifier.clickable { + vm.refreshArtwork() + flash("Artwork cache cleared — covers will reload") + }, + headlineContent = { Text("Refresh artwork") }, + supportingContent = { Text("Reload cover art from the server") }, + trailingContent = { + Icon(Icons.Filled.Refresh, contentDescription = null) + }, + ) + Text( "Server statistics", style = MaterialTheme.typography.titleSmall, diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt index eb8449e..b61c3ce 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/SwipeActions.kt @@ -1,89 +1,122 @@ package ca.ksamad.encore.ui +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp - -/** Fraction of a row's width a swipe must cross before the action fires (vs. the 56.dp default). */ -private const val SWIPE_TRIGGER_FRACTION = 0.5f +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlinx.coroutines.launch /** - * Wraps [content] in the app's shared "queue swipe" affordance: swipe right to [onAddToQueue] - * (append), swipe left to [onPlayNext] (insert after the current track). Used for both album rows + * A swipe only fires when the finger is still moving horizontally faster than this on release — a + * deliberate flick. A slow drag (e.g. the small sideways drift while scrolling the list up and down) + * settles back without triggering, which is the whole point. + */ +private val SWIPE_VELOCITY_THRESHOLD = 450.dp // per second + +/** How far a row can be dragged sideways (enough to show the reveal, then it clamps). */ +private val SWIPE_MAX_REVEAL = 96.dp + +/** Minimum sideways travel before a flick counts, so a fast stationary twitch can't trigger it. */ +private val SWIPE_MIN_DISTANCE = 24.dp + +/** + * Wraps [content] in the app's shared "queue swipe" affordance: flick right to [onAddToQueue] + * (append), flick left to [onPlayNext] (insert after the current track). Used for both album rows * and individual tracks so the gesture and its meaning stay identical wherever it appears. * - * Both are one-shot actions, not deletions, so the row always springs back and stays in the list — - * [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once settled. + * Unlike a dismiss-style swipe, committing is gated on **release velocity**, not distance — so + * accidental sideways movement while scrolling vertically slides a little and springs back instead + * of firing. [content] should be opaque (e.g. a `ListItem`) so it hides the coloured reveal once + * settled at rest. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun QueueSwipeRow( onAddToQueue: () -> Unit, onPlayNext: () -> Unit, content: @Composable () -> Unit, ) { - val state = - rememberSwipeToDismissBoxState( - // Require the swipe to cross half the row's width before it counts, rather - // than the default fixed 56.dp — a small drag or flick was triggering the - // queue/play-next action too easily. - positionalThreshold = { totalDistance -> totalDistance * SWIPE_TRIGGER_FRACTION }, - confirmValueChange = { target -> - when (target) { - SwipeToDismissBoxValue.StartToEnd -> { - onAddToQueue() - } + val scope = rememberCoroutineScope() + val offsetX = remember { Animatable(0f) } - SwipeToDismissBoxValue.EndToStart -> { - onPlayNext() - } + val density = LocalDensity.current + val velocityThresholdPx = with(density) { SWIPE_VELOCITY_THRESHOLD.toPx() } + val maxRevealPx = with(density) { SWIPE_MAX_REVEAL.toPx() } + val minDistancePx = with(density) { SWIPE_MIN_DISTANCE.toPx() } - SwipeToDismissBoxValue.Settled -> {} - } - false // Never settle to dismissed — snap back and keep the row. + Box( + modifier = + Modifier.pointerInput(Unit) { + val tracker = VelocityTracker() + detectHorizontalDragGestures( + onDragStart = { tracker.resetTracking() }, + onHorizontalDrag = { change, dragAmount -> + tracker.addPosition(change.uptimeMillis, change.position) + val target = (offsetX.value + dragAmount).coerceIn(-maxRevealPx, maxRevealPx) + scope.launch { offsetX.snapTo(target) } + change.consume() + }, + onDragEnd = { + val velocity = tracker.calculateVelocity().x + val offset = offsetX.value + // A deliberate flick: fast enough, far enough, and the flick and the + // drag point the same way (so a bounce-back release doesn't count). + val committed = + abs(velocity) >= velocityThresholdPx && + abs(offset) >= minDistancePx && + (velocity > 0f) == (offset > 0f) + if (committed) { + if (offset > 0f) onAddToQueue() else onPlayNext() + } + scope.launch { offsetX.animateTo(0f) } + }, + onDragCancel = { scope.launch { offsetX.animateTo(0f) } }, + ) } - ) - - SwipeToDismissBox( - state = state, - backgroundContent = { SwipeActionBackground(state.dismissDirection) }, ) { - content() + SwipeActionBackground(offset = offsetX.value, modifier = Modifier.matchParentSize()) + Box(modifier = Modifier.offset { IntOffset(offsetX.value.roundToInt(), 0) }) { content() } } } /** - * The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (swipe - * right) and a "Play next" hint on the trailing edge (swipe left). Renders empty while the row is - * settled. + * The coloured reveal shown behind a swiping row: an "Add to queue" hint on the leading edge (drag + * right, [offset] > 0) and a "Play next" hint on the trailing edge (drag left, [offset] < 0). + * Renders empty while the row is settled at rest. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) { - if (direction == SwipeToDismissBoxValue.Settled) { - Box(Modifier.fillMaxSize()) +private fun SwipeActionBackground( + offset: Float, + modifier: Modifier = Modifier, +) { + if (offset == 0f) { + Box(modifier) return } - val queueing = direction == SwipeToDismissBoxValue.StartToEnd + val queueing = offset > 0f val container = if (queueing) { MaterialTheme.colorScheme.secondaryContainer @@ -100,7 +133,7 @@ private fun SwipeActionBackground(direction: SwipeToDismissBoxValue) { val label = if (queueing) "Add to queue" else "Play next" Box( - Modifier.fillMaxSize().background(container).padding(horizontal = 24.dp), + modifier.background(container).padding(horizontal = 24.dp), contentAlignment = if (queueing) Alignment.CenterStart else Alignment.CenterEnd, ) { Row(verticalAlignment = Alignment.CenterVertically) {