Device-surfaced on physical Android 13+ (worked on emulator):
A) The media notification never appeared because the app never
requested POST_NOTIFICATIONS at runtime — the manifest declares it
and the foreground service is correct, but Android 13+ denies it by
default until asked. Add permission_handler ^12.0.1 and request
Permission.notification once at startup (post-first-frame,
Platform.isAndroid-guarded; no-op on <13 / once decided).
B) When the #52 idle/dismiss teardown nulled mediaItem while the full
NowPlayingScreen was open, it stranded the user on an empty
"Nothing playing." Scaffold. Now post-frame maybePop() so it
auto-minimizes (the mini bar is already gone).
pubspec.lock + db.g.dart regenerated by CI/build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.
Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.
track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four related fixes to the player flow that together remove the
audio↔UI lag on track change:
1. **Prefetcher pre-warms covers + palette for the next N tracks.**
The existing prefetcher pinned audio files only. When auto-advance
landed on the next track, the cover bytes were cold → mediaItem
broadcast with artUri=null → now-playing screen stalled in
_scheduleSwap awaiting precacheImage of a file that didn't exist
yet. Each upcoming queue item now also fires
AlbumCoverCache.getOrFetch (writes bytes to disk so _toMediaItem's
peekCached returns the path) and AlbumColorCache.getOrExtract
(memoizes the dominant color). Both fire-and-forget; idempotent
if already cached.
2. **AlbumColorCache.peekColor sync getter** so the now-playing
fast-path can read the memoized color without awaiting a Future.
3. **_scheduleSwap fast path** when cover bytes + palette are
already cached (the common in-queue auto-advance case): commit
_displayedMedia / _displayedDominant synchronously in setState
without awaiting. The async preload remains as the slow-path
fallback for genuine cold cache. This is what closes the gap
the user reported: "art is loading and metadata hasn't updated
but the new song is playing."
4. **setQueueFromTracks: build source before broadcasting queue /
mediaItem.** Previously we broadcast immediately for snappy UI;
if _buildAudioSource threw, the UI showed the new track while
the player held the old source. Now: build first, broadcast
only on success. Source build is sub-100ms on warm cache so the
tap response cost is imperceptible. _suppressIndexUpdates is set
around setAudioSources + broadcast so a transient currentIndex
emission can't cross-broadcast the OLD queue's entry at the NEW
index.
5. **playbackEventStream error handler skips past failing tracks.**
Previously errors only logged. The player would go silent on a
404 / decoder failure but mediaItem stayed on the failed track —
user saw "now playing X" with no audio. Now seekToNext on error;
if at queue tail, pause cleanly so PlaybackState reflects idle.
Regression from v2026.05.13.2's load-then-swap rewrite. _displayedMedia
only got populated by the ref.listen callback on mediaItem changes,
but ref.listen doesn't fire on initial subscription — it only fires
on transitions after the listener is registered. So opening the full
player while a track was already playing left _displayedMedia null
and the screen rendered "Nothing playing." even though the mini bar
showed a live track.
initState now reads the current mediaItem synchronously and seeds
_displayedMedia immediately (and _displayedDominant from the color
provider's cached value when available). A post-frame
_scheduleSwap(current) runs to ensure the cover bytes are decoded
and dominant color resolved when the user opens the player to a
track whose album hasn't yet been color-extracted in this session.
Previous fixes layered AnimatedSwitcher fades on top of a race: the
audio_handler broadcasts MediaItem twice on every track change
(bare metadata first, then with artUri once AlbumCoverCache resolves)
and the image bytes themselves decode asynchronously after the
widget mounts. The fades just smeared the resulting placeholder
flash without addressing the underlying ordering.
Rewrite the decision process around "load first, then swap":
**Mini player** (rapid change is acceptable per operator preference):
- Drop AnimatedSwitcher entirely
- PlayerBar becomes stateful, holds the most-recent non-null artUri
- Builds the child MediaItem with artUri = currentArtUri ?? _lastArtUri,
so the previous cover stays visible across the null-artUri gap and
the new cover snaps in the moment its artUri arrives
**Full player** (operator wants the image fully loaded before any
visible change):
- Introduce _displayedMedia + _displayedDominant state
- ref.listen on mediaItemProvider schedules a preload for each new
track id (and for the artUri-bearing rebroadcast on the same id)
- _scheduleSwap awaits precacheImage on the file:// artUri AND
awaits albumColorProvider's future for the dominant color
- Only then setState flips _displayedMedia + _displayedDominant in
one frame — cover, title, gradient all advance atomically
- Drop the per-element AnimatedSwitcher wrappers; the backdrop
AnimatedContainer still tweens between successive dominant
colors so the gradient transition is smooth, not snap
Concurrency: rapid skips drop stale preload completions via
_pendingPreloadId. Decode/color failures fall through to the
previous dominant + the ServerImage/error-builder fallbacks.
Two related "snap in" effects on the now-playing screen:
1. **Album art snapped in after the fade.** AnimatedSwitcher cross-
fades the new _AlbumArt over 300ms, but FileImage's bytes weren't
decoded yet — the widget was visually empty during the fade and
the cover landed abruptly after. precacheImage on the new file://
artUri pre-decodes the bytes so by the time AnimatedSwitcher
mounts the new tile, the cover paints synchronously inside it.
The cross-fade now carries real content end-to-end.
2. **Backdrop color snapped in.** albumColorProvider.family is
loading for the new id during the track-change moment, so
dominant fell back to fs.obsidian; AnimatedContainer tweened to
obsidian and then snapped to the resolved color a beat later.
_NowPlayingScreenState now holds _lastDominant across builds:
while extraction for the new id is loading, the gradient stays
on the previous album's color, then animates straight to the
new one once it resolves. No intermediate obsidian stop.
Net effect: track changes feel like a single smooth transition
instead of fade-out → blank → snap.
Two issues caught by flutter analyze --fatal-infos:
- dart:ui import in album_color_extractor.dart was redundant because
flutter/painting re-exports the Color it provides. Dropped.
- valueOrNull isn't on AsyncValue in this Riverpod version (the
AsyncValue<Color?> nesting may also have confused the resolver).
Switched to asData?.value which always returns the wrapped value
on AsyncData and null on Loading/Error.
(palette_generator's "discontinued" warning is non-fatal informational
in pub; CI didn't fail on it. The package still works; alternative
swaps deferred until it actually breaks.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three of the four locked items (1, 2, 4); item 6 (swipe-tabs) stays
deferred until server-side lyrics ingestion exists.
1. Dominant-color gradient backdrop. New album_color_extractor.dart
wraps the existing AlbumCoverCache: extracts the dominant color
via PaletteGenerator over the local file, caches in-memory keyed
by album_id. Top 55% of the screen carries the color (0.55 alpha
→ fs.obsidian) so controls below stay legible. AnimatedContainer
tweens the gradient across track changes.
2. Hero transition for cover art (mini bar → full screen). Stable
kPlayerCoverHeroTag (not media.id keyed) so the transition works
regardless of what's playing and isn't racy if media swaps mid-tap.
flightShuttleBuilder renders the destination's Hero widget for the
whole flight, which reads as a clean grow rather than a swap.
4. Crossfade on track change. AnimatedSwitcher around the album art,
title, and artist+album text block, all keyed by media.id so the
switcher fades between old and new on each track-change rebuild.
Pairs with the AnimatedContainer gradient so the whole "what's
playing" zone changes in lockstep.
palette_generator: ^0.3.3 added.
For #396 / #356 umbrella.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two unrelated issues, batched.
Full-player kebab → "Go to album/artist" still crashed with
_debugCheckDuplicatedPageKeys despite the prior onBeforeNavigate
fix. Cause: pop() and push() ran in the same frame, so go_router's
page-key reservation table briefly contained both /now-playing AND
the destination shell-child, tripping the duplicate-key assert.
Refactor: replace onBeforeNavigate with onNavigate(path), where the
host receives the path AFTER the sheet pops and owns the
navigation entirely. The full player wires:
onNavigate: (path) async {
await Navigator.of(context).maybePop();
if (context.mounted) GoRouter.of(context).push(path);
}
Awaiting the pop guarantees /now-playing is fully gone from the
navigator's page list before the push starts.
Mini player + track rows + everywhere else use the default (no
onNavigate) path that does context.push(path) inline — they're
already inside the ShellRoute, no race possible.
Restored alwaysRefresh: true on artistAlbumsProvider. Dropping it
in the cache-loop fix had a side effect: if drift's cachedAlbums
held only a subset of an artist's albums (user previously visited
just one album by them), the artist detail page rendered that
partial list forever — provider only fetched on empty drift, never
on partial drift. The metadata prefetcher only mass-warms
artistProvider (single row), so re-enabling SWR on artistAlbums
won't recreate the storm.
Same root cause as the /queue duplicate-page-key crash: /now-playing
is a top-level route (lives outside the ShellRoute), but
/artists/:id and /albums/:id are shell-children. Pushing a shell-
child from a top-level route makes go_router attempt to mount a
second ShellRoute on top of the active one, leaving navigation in a
broken state. The mini player works because it's already inside the
shell.
Add an optional onBeforeNavigate callback to TrackActionsSheet
(forwarded through TrackActionsButton). When set, fires after
sheet.pop() and before context.push() of the destination route.
Wire the full player's TrackActionsButton with onBeforeNavigate:
() => Navigator.of(context).maybePop() so /now-playing dismisses
itself before the detail route is pushed. Result: clean navigation
into the destination, mini player visible underneath as expected.
Mini player keeps the default (no callback) since it's already in
the shell.
Move shuffle / repeat / queue out from below the play controls and
combine with the like + kebab into a single row sitting just above
the seek bar. Title row drops back to title-only (truly centered now,
no Stack needed since nothing competes for the row's right edge).
Layout order is now: art → title → artist → album → [shuffle, repeat,
queue, like, kebab] → seek → prev/play/next.
The "queue" icon in the top-right of the AppBar stays for now —
redundant with the new row but matches what users have already
muscle-memoried for opening the queue from any player state.
- artist_detail_screen.dart missed an `import '../models/artist.dart'`
for the ArtistRef seed parameter; analyze flagged undefined_class.
- radio.dart + player_provider.dart doc comments wrapped URL
parameters in <...> which lint reads as HTML. Switched to backticks.
Full player title is now centered absolutely via a Stack: title with
horizontal padding equal to the actions cluster width sits at the
optical center, while LikeButton + TrackActionsButton are pinned to
the right edge with Positioned. Fixed-height SizedBox(32) keeps the
row stable when the title wraps to a single ellipsized line.
Three issues, all related to the player surface:
1. Player UI didn't update on track change. audio_handler's
_onCurrentIndexChanged only kicked off the cover load — it never
pushed the new MediaItem onto the mediaItem stream. Title/artist/
cover stayed pinned to whatever setQueueFromTracks(initialIndex:)
set on first play. Now the listener pushes queue[idx] when the
index changes.
2. Player kebab "Go to artist" 404'd while the same item from
MostPlayed worked. Same TrackActionsSheet for both, but the
player's _trackRefFromMediaItem was hardcoding artistId: ''
because audio_handler's _toMediaItem never stashed it in extras.
Stash artist_id alongside album_id; player_bar +
now_playing_screen read it back. Both kebabs now navigate.
3. "Start radio" didn't exist on Flutter even though the server has
/api/radio?seed_track=<id>. New RadioApi (lib/api/endpoints/
radio.dart) wraps the endpoint; PlayerActions.startRadio(trackId)
fetches + plays the result via the existing playTracks path.
New menu item between "Add to playlist" and the divider above
"Go to album", calls startRadio with a snackbar error fallback.
Why the seek bar appeared frozen: it was reading
PlaybackState.updatePosition, which audio_service only updates on
event transitions (play/pause/buffer/seek). Between events it sits
unchanged, so the bar only jumped at intervals.
Expose just_audio's positionStream (~200ms cadence) from the audio
handler, wrap as positionProvider, and read that in both the mini bar
and the full player. Now the bar advances continuously while playing.
While we're here: spread the full player's vertical layout per
operator — gap to seek 20→32, seek-to-primary 8→24, primary-to-
secondary 16→24, plus 24 below to match. The full player had visible
slack above the controls; this redistributes it.
Title (22pt parchment) → artist (14pt ash) → album (12pt ash). Album
is conditional — drops out if MediaItem.album is empty rather than
leaving a blank line. Tightened the gap to the seek bar (24→20) to
absorb the new line on small screens.
Mini player simplifies to track-info + prev/play/next. The
shuffle/repeat/queue cluster moves to the full player, where it has
the room to breathe.
Full player (NowPlayingScreen) now has:
- Real album art (FileImage when artUri is file://, ServerImage from
/api/albums/<id>/cover otherwise — same auth-aware loader as the
rest of the app, with errorBuilder so a failed fetch falls back to
a slate placeholder instead of a broken-image glyph).
- Title row with inline like + kebab.
- Full-width seek with start/end timestamps below.
- 36/72/36 prev/play/next.
- Secondary row: shuffle, repeat, queue.
Mini ↔ Full transition mechanics:
- Tap mini → push /now-playing with custom slide-up transition
(CustomTransitionPage, 280ms easeOutCubic).
- Vertical drag-up flick on mini (>200 px/s) → same push.
- System back / leading expand-more icon → Navigator.pop, which the
CustomTransitionPage replays as a slide-down (240ms easeInCubic).
- Drag-down on full player past 80px OR a >500 px/s downward flick
→ Navigator.pop. Buttons and the seek slider stay tappable since
Flutter's gesture arena gives priority to the more-specific child
recognizers.
- Mini player hides while /now-playing is the active route — the
full player IS the player UI in that state, no need to fight over
visual space.
- TrackRow gains an actions: bool = true param; renders the button
after duration. Album detail + Search + History/Liked tabs all
consume TrackRow so they pick up the menu transitively.
- CompactTrackCard (home Most-played) gets the button at the end of
its inner row.
- _PlaylistTrackRow in playlist_detail_screen gets the button next
to duration; skipped for unavailable rows (no track id).
- NowPlayingScreen renders the button next to the title with
hideQueueActions: true (Play next / Add to queue suppressed since
the menu's track IS the playing one). artistId is left empty since
it's not stashed in MediaItem.extras — Go-to-artist will route to
/artists/ which is benign for v1; a follow-up could stash it.
Closes Tier 1 follow-up #2 (track-level actions menu).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- audio_handler: override skipToQueueItem(index) -> _player.seek(zero, index: i)
- player/queue_screen.dart: list of MediaItems with current track highlighted
(left accent border + Now Playing badge), tap-to-jump on non-current rows
- /queue route + queue_music icon on /now-playing AppBar
Reorder + remove deferred for v1.
Reached by tapping the PlayerBar. Reads media + playback streams; seek
dispatches through the audio handler. Swipe-down dismiss is a stand-in
chevron-down AppBar leading icon for v1.