From 8df41c3bed5bc24559a6fd8ce62e05c3061486dc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:02:27 -0400 Subject: [PATCH 01/13] feat(android): AudioPrefetcher pre-downloads next-N tracks into SimpleCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheSettings.prefetchWindow has shipped at default=5 since M8 but the prefetcher itself was deferred to a follow-up. Without it, forward skips re-fetch from the network every time and gapless transitions stall. New AudioPrefetcher singleton subscribes to PlayerController.uiState + AuthStore.cacheSettings, walks queue[currentIndex+1 .. +window], and runs each missing track through Media3's CacheWriter against the same SimpleCache the player reads from (PlayerFactory.simpleCache). DataSpec uses setKey(trackId) to match PlayerController.toMediaItem's setCustomCacheKey(id) — without this the player would miss the cached bytes on read-through. Reconcile is idempotent: CacheWriter is a no-op when bytes are already resident, so distinctUntilChanged on (queue, index, window) gates re-runs and the per-item check is cheap. Window slides cancel in-flight jobs for tracks that have dropped out (skip-prev, queue rebuild) so a stale prefetch doesn't keep the network busy. Eager-constructed via the construct-the-singleton trick in MinstrelApplication, alongside CacheIndexer and CoverPrefetcher. Mirrors flutter_client/lib/cache/prefetcher.dart's user-visible behavior (queue-walk + per-track pin + idempotent reconcile) implemented with the native Media3 primitives (CacheWriter + SimpleCache) instead of Flutter's AudioCacheManager.pin. --- .../minstrel/MinstrelApplication.kt | 10 ++ .../minstrel/player/AudioPrefetcher.kt | 144 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 522f6e84..9aa83efe 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -13,6 +13,7 @@ import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.LiveEventsDispatcher import com.fabledsword.minstrel.metadata.FreshnessSweeper +import com.fabledsword.minstrel.player.AudioPrefetcher import com.fabledsword.minstrel.player.CoverPrefetcher import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlaybackErrorReporter @@ -103,6 +104,15 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer + /** + * Same construct-the-singleton trick — AudioPrefetcher's init + * block subscribes to PlayerController.uiState + + * AuthStore.cacheSettings and pins the next-N tracks into + * SimpleCache via CacheWriter. Without this @Inject the + * prefetchWindow setting would be inert and skips would re-fetch. + */ + @Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher + /** * Same construct-the-singleton trick — VersionCheckController's * init block starts a 5-min poll loop against /healthz so the diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt new file mode 100644 index 00000000..bbd62a65 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt @@ -0,0 +1,144 @@ +package com.fabledsword.minstrel.player + +import android.net.Uri +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheWriter +import androidx.media3.datasource.okhttp.OkHttpDataSource +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.shared.resolveServerUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Pre-downloads the next-N tracks in the queue into the shared Media3 + * [androidx.media3.datasource.cache.SimpleCache] so a skip-forward or + * natural advance plays from disk instead of waiting on a fresh HTTP + * connection. Mirrors the Flutter `Prefetcher` (cache/prefetcher.dart): + * watches the player's current track, walks forward by + * [com.fabledsword.minstrel.cache.audiocache.CacheSettings.prefetchWindow] + * tracks, and pins each one. Idempotent — `CacheWriter` is a no-op when + * a track is already fully resident, so the reconcile is cheap on + * every queue/index change. + * + * Eager-constructed via the construct-the-singleton trick in + * [com.fabledsword.minstrel.MinstrelApplication]; the init block wires + * the observer. + */ +@Singleton +class AudioPrefetcher @Inject constructor( + @ApplicationScope private val scope: CoroutineScope, + private val playerController: PlayerController, + private val playerFactory: PlayerFactory, + private val authStore: AuthStore, + private val okHttpClient: OkHttpClient, +) { + // Reuse the same cache + upstream chain the player uses. Without the + // SimpleCache reference here the prefetcher would write to a + // disjoint store and playback would never read what we pinned. + private val cacheDataSourceFactory: CacheDataSource.Factory = + CacheDataSource.Factory() + .setCache(playerFactory.simpleCache) + .setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient)) + + // trackId -> in-flight job. Reconcile cancels jobs whose track has + // dropped out of the window (skip-prev, queue rebuild) so a stale + // prefetch doesn't keep the network busy. + private val activeJobs = mutableMapOf() + private val mutex = Mutex() + + init { + scope.launch { + combine( + playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } }, + playerController.uiState.map { it.queueIndex }, + authStore.cacheSettings.map { it.prefetchWindow }, + ) { queue, index, window -> Triple(queue, index, window) } + .distinctUntilChanged() + .collect { (queue, index, window) -> reconcile(queue, index, window) } + } + } + + private suspend fun reconcile( + queue: List>, + index: Int, + window: Int, + ) { + mutex.withLock { + if (index < 0 || queue.isEmpty() || window <= 0) { + cancelAllLocked() + return + } + // Exclude the currently-playing track (it's loaded by the + // player itself) and walk `window` tracks forward. + val firstIdx = (index + 1).coerceAtMost(queue.size) + val lastIdx = (index + window).coerceAtMost(queue.size - 1) + if (firstIdx > lastIdx) { + cancelAllLocked() + return + } + val targets = queue.subList(firstIdx, lastIdx + 1) + val targetIds = targets.mapTo(mutableSetOf()) { it.first } + + // Cancel jobs for tracks that have slid out of the window. + activeJobs.entries + .filter { it.key !in targetIds } + .toList() + .forEach { (id, job) -> + job.cancel() + activeJobs.remove(id) + } + + // Start prefetches for new arrivals. Skip blank URLs (these + // come from minimal TrackRefs synthesized from playlist rows + // when the upstream track was removed from the library). + for ((trackId, streamUrl) in targets) { + if (trackId in activeJobs) continue + if (streamUrl.isBlank()) continue + val job = scope.launch(Dispatchers.IO) { + runCatching { prefetchOne(trackId, streamUrl) } + mutex.withLock { activeJobs.remove(trackId) } + } + activeJobs[trackId] = job + } + } + } + + private fun cancelAllLocked() { + activeJobs.values.forEach { it.cancel() } + activeJobs.clear() + } + + private fun prefetchOne(trackId: String, streamUrl: String) { + // Match PlayerController.toMediaItem's resolveServerUrl + + // setCustomCacheKey(id) so the cached bytes are keyed exactly + // like a normal playback request — otherwise the player would + // miss them on read-through. + val resolved = resolveServerUrl(streamUrl) ?: streamUrl + val dataSpec = DataSpec.Builder() + .setUri(Uri.parse(resolved)) + .setKey(trackId) + .build() + val writer = CacheWriter( + cacheDataSourceFactory.createDataSource(), + dataSpec, + /* temporaryBuffer = */ null, + /* progressListener = */ null, + ) + // Blocking, but we're on Dispatchers.IO. Throws on network + // failure — outer runCatching swallows so a single failed + // prefetch doesn't trip the whole reconcile. + writer.cache() + } +} -- 2.52.0 From 5366cd5634a66df7ae7446b864129f1f8ccde76a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:07:43 -0400 Subject: [PATCH 02/13] fix(android): merge AudioPrefetcher continue guards (detekt LoopWithTooManyJumpStatements) --- .../java/com/fabledsword/minstrel/player/AudioPrefetcher.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt index bbd62a65..9ad8125e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/AudioPrefetcher.kt @@ -104,8 +104,7 @@ class AudioPrefetcher @Inject constructor( // come from minimal TrackRefs synthesized from playlist rows // when the upstream track was removed from the library). for ((trackId, streamUrl) in targets) { - if (trackId in activeJobs) continue - if (streamUrl.isBlank()) continue + if (trackId in activeJobs || streamUrl.isBlank()) continue val job = scope.launch(Dispatchers.IO) { runCatching { prefetchOne(trackId, streamUrl) } mutex.withLock { activeJobs.remove(trackId) } -- 2.52.0 From 2b9b4c1db64ef38164680037883d7056fc95e1be Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:09:59 -0400 Subject: [PATCH 03/13] fix(android): NowPlaying drag-down dismiss via NestedScroll (#2) The previous detectVerticalDragGestures modifier on the Scaffold never fired because the body Column applies verticalScroll, which wins the touch-slop competition for every vertical drag delta before the outer detector sees it. User-visible symptom: swiping down on the NowPlaying screen did nothing. Replace with a NestedScrollConnection. verticalScroll offers its over-scroll deltas to the nearest ancestor connection via the standard nested-scroll protocol; when the column is at the top of its scroll range, downward drag deltas surface in onPostScroll unconsumed (available.y > 0). Accumulate those toward a 200 px threshold and pop the back stack. - Upward over-scroll or any consumed delta resets the accumulator so a partial drag-down followed by drag-up doesn't latch. - onPreFling resets on lift-off so a lazy swipe doesn't dismiss later after the user has released. - Slider thumb retains its own pointerInput and is not affected. - Source filter (UserInput) ignores nested-scroll-driven animations (e.g. flings from inner scrollables). --- .../minstrel/player/ui/NowPlayingScreen.kt | 69 +++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index a1a7af98..074a8e3e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -22,7 +22,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Velocity import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.TopAppBar @@ -30,7 +34,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -122,8 +125,11 @@ fun NowPlayingScreen( return } val dominant = rememberDominantColor(track.coverUrl) + val dismissConnection = rememberDragDismissConnection( + onDismiss = { navController.popBackStack() }, + ) Scaffold( - modifier = Modifier.fillMaxSize().nowPlayingDragDismiss(navController), + modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection), topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) }, snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = Color.Transparent, @@ -161,27 +167,50 @@ private const val DOMINANT_TINT_MID_STOP = 0.45f /** * Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a - * downward drag past the threshold pops the screen, returning the - * user to whichever shell route launched NowPlaying. Tap, swipe-up, - * and horizontal drags pass through to the underlying content - * (slider scrub, etc.). + * downward over-scroll past the threshold pops the screen. + * + * Implementation note: the body Column uses verticalScroll, which + * eats every vertical drag delta before a top-level + * detectVerticalDragGestures can see it. NestedScroll is the + * right primitive here — verticalScroll offers its over-scroll + * deltas to the nearest ancestor connection, so we accumulate + * downward over-scroll (available.y > 0 means the scrollable + * couldn't consume it because it's at the top) and pop on + * threshold. The Slider keeps its own pointerInput and is + * unaffected. */ @Composable -private fun Modifier.nowPlayingDragDismiss(navController: NavHostController): Modifier = - this.pointerInput(Unit) { - var totalDragY = 0f - detectVerticalDragGestures( - onDragStart = { totalDragY = 0f }, - onDragEnd = { - if (totalDragY > DRAG_DISMISS_THRESHOLD_PX) { - navController.popBackStack() +private fun rememberDragDismissConnection( + onDismiss: () -> Unit, + thresholdPx: Float = DRAG_DISMISS_THRESHOLD_PX, +): NestedScrollConnection = remember(onDismiss, thresholdPx) { + object : NestedScrollConnection { + private var accumulated = 0f + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + if (available.y > 0f) { + accumulated += available.y + if (accumulated >= thresholdPx) { + accumulated = 0f + onDismiss() } - totalDragY = 0f - }, - onDragCancel = { totalDragY = 0f }, - onVerticalDrag = { _, delta -> totalDragY += delta }, - ) + return Offset(0f, available.y) + } + if (consumed.y != 0f || available.y < 0f) accumulated = 0f + return Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + accumulated = 0f + return Velocity.Zero + } } +} @OptIn(ExperimentalMaterial3Api::class) @Composable -- 2.52.0 From 3d52f271a08dcad1471d45b818ffb338431b95b4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:14:39 -0400 Subject: [PATCH 04/13] fix(android): collapse onPostScroll branches (detekt ReturnCount) --- .../fabledsword/minstrel/player/ui/NowPlayingScreen.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 074a8e3e..42bec473 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -193,16 +193,17 @@ private fun rememberDragDismissConnection( source: NestedScrollSource, ): Offset { if (source != NestedScrollSource.UserInput) return Offset.Zero - if (available.y > 0f) { + return if (available.y > 0f) { accumulated += available.y if (accumulated >= thresholdPx) { accumulated = 0f onDismiss() } - return Offset(0f, available.y) + Offset(0f, available.y) + } else { + if (consumed.y != 0f || available.y < 0f) accumulated = 0f + Offset.Zero } - if (consumed.y != 0f || available.y < 0f) accumulated = 0f - return Offset.Zero } override suspend fun onPreFling(available: Velocity): Velocity { -- 2.52.0 From c23df8d8af577ecfbfe05bb958c50d1c54d931f2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:17:11 -0400 Subject: [PATCH 05/13] fix(android): stop Home Crossfade firing on every section emission (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-visible: Home flickered continuously after first sign-in until all sections settled. The top-level Crossfade keyed on the state INSTANCE — and because each section's flow emission produces a new UiState.Success(data), Crossfade ran its 300ms fade animation on every per-section hydration tick. Six sections cascading in over ~1s read as continuous flicker. Fix: key the Crossfade on state::class. Loading -> Success -> Empty -> Error class transitions still animate; Success -> Success(with more sections) recompositions just update the LazyColumn normally through Compose's standard diff path. --- .../com/fabledsword/minstrel/home/ui/HomeScreen.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index e9048195..c2b03bc4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -367,8 +367,17 @@ fun HomeScreen( onRefresh = { viewModel.refresh().join() }, modifier = Modifier.fillMaxSize().padding(inner), ) { - Crossfade(targetState = state, label = "home-state") { s -> - when (s) { + // Key Crossfade on the state CLASS, not the instance. Each + // section emission produces a new UiState.Success(data); if + // we keyed on `state` directly, every per-section + // hydration tick would re-run the 300ms crossfade, and + // first-sign-in (six sections cascading in) reads as + // continuous flicker. Keying on the class restricts the + // animation to Loading↔Success↔Empty↔Error transitions and + // lets normal Success→Success recompositions update the + // LazyColumn without a fade. + Crossfade(targetState = state::class, label = "home-state") { _ -> + when (val s = state) { UiState.Loading -> HomeSkeletonContent() UiState.Empty -> EmptyState( title = "Welcome to Minstrel", -- 2.52.0 From 8ecb2cf553bc0e570edcb067d211f608f3a0eaf6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:27:17 -0400 Subject: [PATCH 06/13] feat(android): MiniPlayer fill bar + slim NowPlaying scrubber + smooth playhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three closely-related player polish changes: 1. MiniPlayer (#74) — the bottom bar's progress is a 4dp Box-based fill, not a Slider. No thumb, no drag handle. Tapping the bar still expands to NowPlaying where scrubbing lives. 2. NowPlaying scrubber (#75) — keep the M3 Slider (still interactive for seek) but shrink the thumb from the 20dp default to 10dp via SliderDefaults.Thumb's thumbSize slot. Slider's own 48dp hit slop is unchanged so tappability stays. Spacers above and below ScrubberRow drop from 8dp to 4dp. 3. Smooth playhead (#76) — new SmoothPosition.kt with rememberSmoothPositionMs(). PlayerController.uiState polls the underlying ExoPlayer position roughly every 500ms; reading it directly steps the scrubber by half-seconds, which reads as jumpy. The helper resets to the canonical positionMs on each tick (and on seek/track-change/duration-change) and runs a withFrameMillis loop while isPlaying to advance the displayed value at 1ms/ms between ticks. Pause/end/no-duration short- circuit the loop. Both MiniPlayer's fill bar and the NowPlaying scrubber consume the smoothed value. --- .../minstrel/player/ui/MiniPlayer.kt | 42 +++++++++------- .../minstrel/player/ui/NowPlayingScreen.kt | 45 ++++++++++++----- .../minstrel/player/ui/SmoothPosition.kt | 48 +++++++++++++++++++ 3 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index 8228d5bd..b2061931 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -20,8 +20,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -148,10 +147,19 @@ fun MiniPlayer( tonalElevation = MINI_BAR_TONAL_ELEVATION_DP.dp, ) { Column { - MiniSeekBar( + // Non-interactive fill bar — no scrubber thumb. Tapping + // the bar still expands to NowPlaying where scrubbing + // lives. The displayed fraction interpolates per-frame + // between PlayerController position ticks so the fill + // glides instead of stepping every 500ms. + val smoothPositionMs by rememberSmoothPositionMs( positionMs = state.positionMs, durationMs = state.durationMs, - onSeek = viewModel::seekTo, + isPlaying = state.isPlaying, + ) + MiniProgressFill( + positionMs = smoothPositionMs, + durationMs = state.durationMs, ) MiniRow( track = track, @@ -170,27 +178,25 @@ fun MiniPlayer( } @Composable -private fun MiniSeekBar(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { +private fun MiniProgressFill(positionMs: Long, durationMs: Long) { val fraction = if (durationMs > 0) { (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) } else { 0f } - Slider( - value = fraction, - onValueChange = { v -> if (durationMs > 0) onSeek((v * durationMs).toLong()) }, + Box( modifier = Modifier .fillMaxWidth() - .height(SCRUBBER_ROW_HEIGHT_DP.dp), - colors = SliderDefaults.colors( - thumbColor = MaterialTheme.colorScheme.primary, - activeTrackColor = MaterialTheme.colorScheme.primary, - // M3 default inactiveTrackColor is secondaryContainer, which the - // theme doesn't override — that's where the M3-baseline purple - // leaks in. Pin to surfaceVariant (= slate) for Flutter parity. - inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) + .height(SCRUBBER_ROW_HEIGHT_DP.dp) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction) + .background(MaterialTheme.colorScheme.primary), + ) + } } @Composable diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 42bec473..96f24442 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -28,10 +28,12 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.Velocity import androidx.compose.material3.Scaffold +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Slider +import androidx.compose.ui.unit.DpSize import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.material3.SliderDefaults @@ -271,13 +273,18 @@ private fun NowPlayingBody( onToggleShuffle = viewModel::toggleShuffle, onCycleRepeat = viewModel::cycleRepeat, ) - Spacer(Modifier.height(8.dp)) - ScrubberRow( + Spacer(Modifier.height(4.dp)) + val smoothPositionMs by rememberSmoothPositionMs( positionMs = state.positionMs, durationMs = state.durationMs, + isPlaying = state.isPlaying, + ) + ScrubberRow( + positionMs = smoothPositionMs, + durationMs = state.durationMs, onSeek = viewModel::seekTo, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) TransportRow( isPlaying = state.isPlaying, onPrev = viewModel::skipToPrevious, @@ -426,6 +433,7 @@ private fun TrackHeader(title: String, artist: String, album: String) { } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) { val accent = MaterialTheme.colorScheme.primary @@ -434,19 +442,34 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un } else { 0f } + val interactionSource = remember { MutableInteractionSource() } + val sliderColors = SliderDefaults.colors( + thumbColor = accent, + activeTrackColor = accent, + // M3 inactive default is secondaryContainer (purple baseline); + // pin to surfaceVariant (= slate) for Flutter parity. + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + ) Slider( value = fraction, onValueChange = { newFraction -> if (durationMs > 0) onSeek((newFraction * durationMs).toLong()) }, modifier = Modifier.fillMaxWidth(), - colors = SliderDefaults.colors( - thumbColor = accent, - activeTrackColor = accent, - // M3 inactive default is secondaryContainer (purple baseline); - // pin to surfaceVariant (= slate) for Flutter parity. - inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, - ), + colors = sliderColors, + interactionSource = interactionSource, + // Slim 10dp thumb shrinks the scrubber's visual weight. M3 + // default is 20dp; halving keeps it tappable (Slider's own + // 48dp hit slop is unchanged) but lets it recede into the UI. + // Track left at default — the colour pin alone already + // matches Flutter's slate look. + thumb = { + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = sliderColors, + thumbSize = DpSize(10.dp, 10.dp), + ) + }, ) Row( modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt new file mode 100644 index 00000000..18003011 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/SmoothPosition.kt @@ -0,0 +1,48 @@ +package com.fabledsword.minstrel.player.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.compose.runtime.withFrameMillis +import kotlinx.coroutines.isActive + +/** + * Per-frame interpolated playhead. `positionMs` comes in at the + * polling cadence of [com.fabledsword.minstrel.player.PlayerController] + * (~500ms today) — using it directly for the scrubber paints a sudden + * 500ms jump on each tick. This wraps it in a [produceState] that + * resets to the canonical value on every emission and then advances + * forward at 1ms/ms while the player is playing, so the displayed + * position moves continuously at the natural playback rate. + * + * On track end / pause / seek the produceState's keys change and the + * coroutine restarts from the new canonical value — so when the + * canonical position changes by more than a frame can account for + * (skip, scrub, seek), we snap instead of slewing across the bar. + * + * For paused state the position stays at the canonical value with no + * extrapolation; for ended/durationMs<=0 the helper returns + * `positionMs` unchanged. + */ +@Composable +fun rememberSmoothPositionMs( + positionMs: Long, + durationMs: Long, + isPlaying: Boolean, +): State = produceState( + initialValue = positionMs, + key1 = positionMs, + key2 = isPlaying, + key3 = durationMs, +) { + value = positionMs + if (!isPlaying || durationMs <= 0L) return@produceState + val startFrame = withFrameMillis { it } + val startPos = positionMs + while (isActive) { + withFrameMillis { now -> + val advanced = startPos + (now - startFrame) + value = advanced.coerceAtMost(durationMs) + } + } +} -- 2.52.0 From d99de1af27b6bd816ed9943c484d9c74374cc7c6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:30:17 -0400 Subject: [PATCH 07/13] =?UTF-8?q?feat(android):=20Recently=20Added=20?= =?UTF-8?q?=E2=86=92=20single=20LazyHorizontalGrid=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously Recently Added chunked into rows of 25 and rendered each chunk as its own LazyRow, so chunks scrolled independently. Same pattern Most Played uses (one LazyHorizontalGrid where all rows scroll as a single panel) now applies to Recently Added. - New RECENTLY_ADDED_GRID_ROWS = 2 + RECENTLY_ADDED_GRID_HEIGHT_DP = 440 (matches a 200dp tile × 2 rows + the 8dp inter-row gap). - New RecentlyAddedGrid composable owns the section header + the LazyHorizontalGrid. Column-major re-flattening matches Web's row-major reading order on screen (top row first, then bottom). - recentlyAddedSection collapses from itemsIndexed-over-chunks to a single item { RecentlyAddedGrid(...) } in the outer LazyColumn. - AlbumsRow stays untouched (still used by Rediscover, etc.). The other multi-section helpers (Rediscover, Last Played, Playlists) are single-row by design and don't need this treatment. --- .../minstrel/home/ui/HomeScreen.kt | 81 ++++++++++++++++--- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index c2b03bc4..ae5a0ed9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -101,7 +101,12 @@ import javax.inject.Inject private const val SHARE_STOP_TIMEOUT_MS = 5_000L private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140 -private const val RECENTLY_ADDED_CHUNK = 25 +// Recently Added is laid out in a multi-row LazyHorizontalGrid that +// scrolls as one panel (same pattern as Most Played). Two rows trades +// a tall block for fewer columns to scroll past — a 200dp tile × 2 = +// ~440dp section still leaves room for the rest of Home. +private const val RECENTLY_ADDED_GRID_ROWS = 2 +private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440 // ─── State ─────────────────────────────────────────────────────────── @@ -512,23 +517,75 @@ private fun LazyListScope.recentlyAddedSection( } return } - // Chunk into stacked carousels of 25 so a large library reads as - // multiple rows instead of one unwieldy LazyRow. Only the first - // chunk carries the section title; the rest are continuation rows. - val chunks = albums.chunked(RECENTLY_ADDED_CHUNK) - itemsIndexed( - items = chunks, - key = { index, chunk -> "recent-$index-${chunk.first().id}" }, - ) { index, chunk -> - AlbumsRow( - title = if (index == 0) "Recently added" else "", - albums = chunk, + item { + RecentlyAddedGrid( + albums = albums, onAlbumClick = onAlbumClick, onPlayAlbum = onPlayAlbum, ) } } +/** + * Multi-row horizontal grid for Recently Added, mirroring the + * Most Played pattern: tiles laid out column-major across + * [RECENTLY_ADDED_GRID_ROWS] rows inside one LazyHorizontalGrid so + * every row scrolls together as one panel. Replaces the previous + * stacked-LazyRows layout which scrolled each chunk independently. + */ +@Composable +private fun RecentlyAddedGrid( + albums: List>, + onAlbumClick: (String) -> Unit, + onPlayAlbum: suspend (String) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Recently added", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + // Re-flatten column-major to match Web's "ranks across the + // top row, then across the middle, then bottom" reading + // order. LazyHorizontalGrid otherwise fills column-major + // directly from a flat list and would scramble per-rank + // expectations. + val perRow = (albums.size + RECENTLY_ADDED_GRID_ROWS - 1) / RECENTLY_ADDED_GRID_ROWS + val rows = albums.chunked(perRow.coerceAtLeast(1)) + val cols = rows.maxOfOrNull { it.size } ?: 0 + val ordered = buildList { + for (c in 0 until cols) { + for (r in 0 until RECENTLY_ADDED_GRID_ROWS) { + rows.getOrNull(r)?.getOrNull(c)?.let { add(it) } + } + } + } + LazyHorizontalGrid( + rows = GridCells.Fixed(RECENTLY_ADDED_GRID_ROWS), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .height(RECENTLY_ADDED_GRID_HEIGHT_DP.dp), + ) { + gridItems(items = ordered, key = { it.id }) { tile -> + val album = tile.value + if (album == null) { + SkeletonAlbumTile() + } else { + AlbumCard( + album = album, + onClick = { onAlbumClick(album.id) }, + onPlay = { onPlayAlbum(album.id) }, + ) + } + } + } + } +} + private fun LazyListScope.rediscoverSection( albums: List>, artists: List>, -- 2.52.0 From c9afd4f584bf4481c23f7055e4d6f9e9973acd43 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:54:16 -0400 Subject: [PATCH 08/13] feat(web): card hover-reveal + drop year + accent rule on section headers First wave of Home visual polish (UI tasks 78/79/81/85): - CardActionCluster like/queue/menu cluster is now opacity-0 and fades in on group-hover or focus-within. The group class moves from the inner anchor to the outer card wrapper so the cluster (positioned outside the anchor) participates in the same hover scope. PlaylistCard refresh kebab gets the same treatment. - AlbumCard drops the year line. Title plus artist is enough on Home tiles; the album detail page still shows the year. - HorizontalScrollRow h2 picks up a flex-baseline layout with a trailing 12-wide accent rule (after:bg-accent/60), so section headers read as chapter breaks instead of identical plain text. - AlbumCard art-wrap is shadow-sm by default and lifts to shadow-lg plus ring-accent/40 on hover. Pairs with the existing group-hover:scale-1.03 for a clean lift-on-mouseover feel. --- web/src/lib/components/AlbumCard.svelte | 13 +++++++------ web/src/lib/components/ArtistCard.svelte | 4 ++-- web/src/lib/components/CardActionCluster.svelte | 16 ++++++++++++++-- .../lib/components/HorizontalScrollRow.svelte | 9 ++++++++- web/src/lib/components/PlaylistCard.svelte | 9 +++++++-- 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/web/src/lib/components/AlbumCard.svelte b/web/src/lib/components/AlbumCard.svelte index 5575ccb1..69712910 100644 --- a/web/src/lib/components/AlbumCard.svelte +++ b/web/src/lib/components/AlbumCard.svelte @@ -28,12 +28,16 @@ } -
+
-
+
{album.artist_name}
{/if} - {#if album.year} -
{album.year}
- {/if}
-
+
{#if artist.cover_url} diff --git a/web/src/lib/components/CardActionCluster.svelte b/web/src/lib/components/CardActionCluster.svelte index c92644b2..54d80130 100644 --- a/web/src/lib/components/CardActionCluster.svelte +++ b/web/src/lib/components/CardActionCluster.svelte @@ -34,9 +34,17 @@ } = $props(); + -
e.stopPropagation()}> +
e.stopPropagation()} +>
-
{playlist.name}
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'} {#if !isOwn} diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index c3de4804..747da9c6 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -135,7 +135,16 @@
-
+ +
{@render children?.()}
diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index a6e0d6a7..9654760f 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -142,7 +142,9 @@ ariaLabel="Playlists" > {#snippet item(rowItem: PlaylistRowItem)} -
+ +
{#if rowItem.kind === 'real'} {:else} @@ -195,7 +197,9 @@ ariaLabel="Rediscover albums" > {#snippet item(album: import('$lib/api/types').AlbumRef)} -
+ +
{/snippet} {/if} -- 2.52.0 From 5b25f89c01c8db523b61c8be07217aec92bbfe87 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 19:59:40 -0400 Subject: [PATCH 10/13] test(web): update AlbumCard test for dropped year line --- web/src/lib/components/AlbumCard.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/AlbumCard.test.ts b/web/src/lib/components/AlbumCard.test.ts index 307c2b52..79d55b20 100644 --- a/web/src/lib/components/AlbumCard.test.ts +++ b/web/src/lib/components/AlbumCard.test.ts @@ -53,18 +53,22 @@ const album: AlbumRef = { afterEach(() => vi.clearAllMocks()); describe('AlbumCard', () => { - test('renders cover, title, year inside a link to /albums/:id', () => { + test('renders cover, title, artist inside a link to /albums/:id', () => { const { container } = render(AlbumCard, { props: { album } }); const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/albums/xyz'); expect(link).toHaveTextContent('Kind of Blue'); - expect(link).toHaveTextContent('1959'); + expect(link).toHaveTextContent('Miles Davis'); + // The year is intentionally omitted from the Home tile — + // dropped in the visual polish pass; the album detail page + // still shows it. + expect(link).not.toHaveTextContent('1959'); const img = container.querySelector('img') as HTMLImageElement; expect(img.src).toContain('/api/albums/xyz/cover'); }); - test('year is omitted when not present', () => { + test('year is not rendered on the tile even when present', () => { render(AlbumCard, { props: { album: { ...album, year: undefined } } }); expect(screen.queryByText(/1959/)).not.toBeInTheDocument(); }); -- 2.52.0 From 5f297c4c2166293a4cbae15a893d44aab9adcf7a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 20:03:48 -0400 Subject: [PATCH 11/13] feat(web): dominant-color accent strip on PlayerBar + bigger Home tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9 — new lib/media/dominantColor.ts: load cover image, downsample to 1x1 canvas, read pixel. Approximates the dominant tone via the browser's bilinear mean — close enough for an ambient accent without the 5KB ColorThief dependency. Same-origin cover URLs so no CORS dance. Result cached by URL so revisits are free. PlayerBar samples the current track's cover and pipes the resulting rgb into a 2px accent strip above both the compact and desktop variants. Transparent until the first resolve; 300ms transition on colour change so track-skips fade rather than snap. #10 — slight bump to Home tile widths so a typical viewport shows roughly 5-6 across instead of cramming 8-9: Playlists w-56, all remaining AlbumCard rows w-48 (Recently Added + both Rediscover album scrollers). Replaces the wave-2 sizes that were still showing 7-8 across on wider screens. --- web/src/lib/components/PlayerBar.svelte | 36 +++++++++++++++++ web/src/lib/media/dominantColor.ts | 53 +++++++++++++++++++++++++ web/src/routes/+page.svelte | 7 ++-- 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/media/dominantColor.ts diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte index 68caddcf..fc9bdc0a 100644 --- a/web/src/lib/components/PlayerBar.svelte +++ b/web/src/lib/components/PlayerBar.svelte @@ -13,6 +13,7 @@ } from '$lib/player/store.svelte'; import { formatDuration } from '$lib/media/duration'; import { FALLBACK_COVER, coverUrl } from '$lib/media/covers'; + import { dominantColorFromUrl, rgbToCssString } from '$lib/media/dominantColor'; import LikeButton from './LikeButton.svelte'; import TrackMenu from './TrackMenu.svelte'; @@ -44,6 +45,24 @@ function onCoverError(e: Event) { (e.currentTarget as HTMLImageElement).src = FALLBACK_COVER; } + + // #9 — dominant-color accent. Sample the current track's cover and + // pipe it into a CSS custom property; a 2px strip above the bar + // takes on the colour, so the page subtly tracks what's playing. + // Cache-backed in dominantColorFromUrl so revisits are free. + // Default ('') keeps the strip transparent until the first cover + // resolves. + let accentRgb = $state(''); + $effect(() => { + const cover = current?.album_id ? coverUrl(current.album_id) : null; + if (!cover) { + accentRgb = ''; + return; + } + dominantColorFromUrl(cover).then((rgb) => { + if (rgb) accentRgb = rgbToCssString(rgb); + }); + }); {#if current} @@ -59,6 +78,16 @@ Play controls maintain 40/48/40 size; like+kebab and column-3 sub-rows are shorter so column 2's bottom edge aligns with column 3's bottom. --> + +
+ +