Compare commits

..

4 Commits

Author SHA1 Message Date
bvandeusen 573aa4226d Merge pull request 'release v2026.05.13.2: artist covers + load-then-swap player transitions' (#45) from dev into main 2026-05-14 16:31:43 +00:00
bvandeusen 30fc7603d4 chore(flutter): bump versionCode to 3 for v2026.05.13.2 hotfix 2026-05-14 12:31:05 -04:00
bvandeusen 86d67f6fc6 fix(player): preload-then-swap cover transitions on both player surfaces
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.
2026-05-14 12:07:50 -04:00
bvandeusen 8cb9a8b797 fix(flutter): restore artist coverUrl on drift-cached ArtistRef
Multi-artist surfaces (home Rediscover, Library Artists tab, Liked
Artists carousel) were all rendering the music-notes placeholder
instead of real artist covers. Root cause:

CachedArtist.toRef() returned an ArtistRef with empty coverUrl —
the cache doesn't store the representative album id the server
derives at query time, and the adapter never reconstructed it.
The artist detail screen worked coincidentally because it derives
its header cover from `artist.albums[0].id` directly rather than
the ArtistRef's coverUrl field.

Fix:
* CachedArtistAdapter.toRef() now accepts an optional coverAlbumId
  and reconstructs `/api/albums/<id>/cover` when given. Matches the
  pattern AlbumRef uses (deterministic URL from entity id).
* artistTileProvider, libraryArtistsProvider, and artistProvider
  (single-artist) each LEFT JOIN cached_albums ordered by sort_title.
  First row per artist carries the alphabetically-first album id;
  toRef projects that into the cover URL.
* Multi-artist queries dedup in toResult since the join multiplies
  rows by album count.

Artists with no albums yet in drift come through with empty
coverUrl — UI falls back to the music-notes placeholder, same
behavior as before for that legitimately-coverless state.
2026-05-14 12:01:02 -04:00
7 changed files with 272 additions and 131 deletions
+12 -1
View File
@@ -17,10 +17,21 @@ import '../models/track.dart';
import 'db.dart';
extension CachedArtistAdapter on CachedArtist {
ArtistRef toRef() => ArtistRef(
/// `coverAlbumId` lets the caller pass a representative album id so
/// the ArtistRef carries a reconstructed `/api/albums/<id>/cover`
/// URL. cached_artists doesn't store this directly — the server
/// derives it from the most-recent album at query time — so drift
/// readers that want the cover URL join cached_albums and pass the
/// first album's id through. Empty string yields empty coverUrl,
/// matching the server's behavior for endpoints that don't carry
/// the lookup (artist detail, search, raw cached_artists row reads).
ArtistRef toRef({String coverAlbumId = ''}) => ArtistRef(
id: id,
name: name,
sortName: sortName,
coverUrl: coverAlbumId.isNotEmpty
? '/api/albums/$coverAlbumId/cover'
: '',
);
}
+19 -2
View File
@@ -18,6 +18,7 @@
// subscriptions; drift handles this fine in practice but worth
// measuring if a future surface scales to hundreds.
import 'package:drift/drift.dart' as drift show OrderingTerm;
import 'package:drift/drift.dart' show leftOuterJoin;
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -63,6 +64,12 @@ final albumTileProvider =
/// Watches the cached_artists row for [id]. Triggers background
/// hydration on miss; yields the row once it lands.
///
/// LEFT JOIN cached_albums ordered by sort_title so the first row per
/// artist carries a representative album id; toRef() reconstructs
/// `/api/albums/<id>/cover` from it. Without this join the ArtistRef
/// has an empty coverUrl and tiles fall back to the music-notes
/// placeholder even though the server has the cover available.
final artistTileProvider =
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
if (id.isEmpty) {
@@ -70,7 +77,12 @@ final artistTileProvider =
return;
}
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedArtists)..where((t) => t.id.equals(id));
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.join([
leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
var enqueued = false;
await for (final rows in query.watch()) {
if (rows.isEmpty) {
@@ -83,7 +95,12 @@ final artistTileProvider =
yield null;
continue;
}
yield rows.first.toRef();
// First row has the alphabetically-first album (or null if artist
// has no albums in drift yet — yields a coverless ArtistRef which
// the UI falls back to the placeholder icon for).
final artist = rows.first.readTable(db.cachedArtists);
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
yield artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
}
});
@@ -233,17 +233,28 @@ Map<String, dynamic> _trackToJson(TrackRef t) => {
final artistProvider =
StreamProvider.family<ArtistRef, String>((ref, id) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedArtist, ArtistRef>(
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.watch(),
// LEFT JOIN cached_albums for cover-URL reconstruction (see
// CachedArtistAdapter.toRef). First row carries the alphabetically-
// first album.
final query = (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.join([
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([drift.OrderingTerm.asc(db.cachedAlbums.sortTitle)]);
return cacheFirst<drift.TypedResult, ArtistRef>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtist(id);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
},
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
toResult: (rows) {
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
final artist = rows.first.readTable(db.cachedArtists);
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
+30 -4
View File
@@ -62,9 +62,19 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
/// via /api/library/sync.
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedArtists)
..orderBy([(t) => drift.OrderingTerm.asc(t.sortName)]);
return cacheFirst<CachedArtist, List<ArtistRef>>(
// LEFT JOIN cached_albums so each artist row carries a representative
// album id for cover-URL reconstruction. Sorted by artist sortName
// then album sortTitle: the first row per artist gets the
// alphabetically-first album. Dedup happens in toResult.
final query = db.select(db.cachedArtists).join([
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.artistId.equalsExp(db.cachedArtists.id)),
])
..orderBy([
drift.OrderingTerm.asc(db.cachedArtists.sortName),
drift.OrderingTerm.asc(db.cachedAlbums.sortTitle),
]);
return cacheFirst<drift.TypedResult, List<ArtistRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
// Cold-cache fallback: sync should have populated drift already,
@@ -80,7 +90,23 @@ final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
);
});
},
toResult: (rows) => rows.map((r) => r.toRef()).toList(),
toResult: (rows) {
// The join multiplies rows by album count; dedup by artist id
// keeping the first occurrence so we get one ArtistRef per
// artist with the alphabetically-first album's id for the
// cover-URL projection. Artists with no albums in drift yet
// come through as a single row with null cachedAlbums and an
// empty coverUrl — UI falls back to the placeholder icon.
final seen = <String>{};
final out = <ArtistRef>[];
for (final r in rows) {
final a = r.readTable(db.cachedArtists);
if (!seen.add(a.id)) continue;
final album = r.readTableOrNull(db.cachedAlbums);
out.add(a.toRef(coverAlbumId: album?.id ?? ''));
}
return out;
},
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
+152 -86
View File
@@ -45,16 +45,72 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// dismiss. Reset on each drag start.
double _dragOffset = 0;
/// Last resolved dominant color. Held across track changes so the
/// gradient backdrop tweens smoothly from the previous album's color
/// to the next, instead of dropping through fs.obsidian during the
/// brief moment albumColorProvider for the new id is still loading.
Color? _lastDominant;
/// The MediaItem currently displayed on the screen. Held separately
/// from the live mediaItemProvider so we can preload the new track's
/// cover bytes + dominant color BEFORE flipping the visible state.
/// Without this gate the full-player UI swapped to the new track's
/// title/cover slot immediately while the image was still decoding,
/// producing a visible "pop in" once the bytes arrived.
MediaItem? _displayedMedia;
/// Dedupe key for the cover precache side-effect. Without this we'd
/// fire precacheImage on every MediaItem rebroadcast (twice per track
/// change, once on artUri arrival).
String? _precachedArtUri;
/// Dominant color of [_displayedMedia]'s cover. Tweened by the
/// backdrop AnimatedContainer when it changes.
Color? _displayedDominant;
/// The id of the most-recent track we kicked a preload for. Used to
/// drop stale preload completions when the user skips rapidly past
/// a track whose cover hadn't finished decoding yet.
String? _pendingPreloadId;
/// Preload the new track's cover bytes + dominant color, then
/// atomically flip _displayedMedia / _displayedDominant. Until both
/// resolve, the screen continues to render the previous track —
/// the new title/cover/gradient appear together in one frame.
///
/// Concurrency: if a second track change arrives while the first
/// preload is still in flight, the older preload's
/// _pendingPreloadId check fails and its completion is dropped.
Future<void> _scheduleSwap(MediaItem newMedia) async {
_pendingPreloadId = newMedia.id;
// 1. Precache the cover image bytes so when _AlbumArt mounts with
// the new media, FileImage paints synchronously. Non-file
// artUris fall through to ServerImage which handles its own
// network load + 120ms fade — they won't snap.
final artUri = newMedia.artUri;
if (artUri != null && artUri.isScheme('file') && context.mounted) {
try {
await precacheImage(FileImage(File.fromUri(artUri)), context);
} catch (_) {
// Decode failed (missing file, corrupt bytes) — proceed
// anyway; _AlbumArt's errorBuilder shows the slate fallback.
}
}
// 2. Wait for the dominant-color extraction so the gradient is
// populated when we swap. PaletteGenerator runs against the
// same FileImage, typically resolving within ~50ms once the
// decode completes.
Color? newDominant;
final albumId = newMedia.extras?['album_id'] as String?;
if (albumId != null && albumId.isNotEmpty) {
try {
newDominant = await ref.read(albumColorProvider(albumId).future);
} catch (_) {
// Color extraction failed — keep the previous dominant so the
// backdrop doesn't drop to obsidian on the swap.
}
}
// 3. Atomic flip. Bail if a newer swap superseded us, or the
// screen was disposed mid-preload.
if (!mounted) return;
if (_pendingPreloadId != newMedia.id) return;
setState(() {
_displayedMedia = newMedia;
if (newDominant != null) _displayedDominant = newDominant;
});
}
void _onDragStart(DragStartDetails _) {
_dragOffset = 0;
@@ -78,10 +134,61 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (media == null) {
// Listen for mediaItem changes and schedule a preload-then-swap.
// Build renders _displayedMedia / _displayedDominant; the live
// mediaItemProvider only drives the swap pipeline.
//
// Decision process:
// - On the first non-null mediaItem after mount, seed
// _displayedMedia immediately so the screen has something to
// paint.
// - On a track id change, kick off a preload. The new track's
// cover bytes + dominant color must both resolve before we
// flip the displayed state. The user sees the previous track
// in full until the new one is fully ready, then a clean
// atomic swap (no fade, no placeholder flash).
// - On the same track id but the artUri-bearing rebroadcast
// (audio_handler sends MediaItem twice on track change),
// refresh through the same preload pipeline so the displayed
// cover reflects the freshly-written AlbumCoverCache file.
ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (prev, next) {
final newMedia = next.asData?.value;
if (newMedia == null) {
if (_displayedMedia != null) {
setState(() {
_displayedMedia = null;
_displayedDominant = null;
_pendingPreloadId = null;
});
}
return;
}
if (_displayedMedia == null) {
// First mount with a track playing — seed immediately, then
// kick the preload pipeline to update the dominant color and
// ensure the cover is decoded.
setState(() => _displayedMedia = newMedia);
_scheduleSwap(newMedia);
return;
}
final isNewTrack = newMedia.id != _displayedMedia!.id;
final coverGotPopulated =
newMedia.id == _displayedMedia!.id &&
newMedia.artUri != null &&
_displayedMedia!.artUri == null;
if (isNewTrack || coverGotPopulated) {
_scheduleSwap(newMedia);
}
});
final playback = ref.watch(playbackStateProvider).value;
final displayedMedia = _displayedMedia;
if (displayedMedia == null) {
// Either nothing playing, or the very first mount before a
// mediaItem has been emitted. The ref.listen above will seed
// _displayedMedia as soon as a non-null MediaItem arrives.
return const Scaffold(body: Center(child: Text('Nothing playing.')));
}
@@ -90,53 +197,22 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
// only fires on state transitions and would leave the bar frozen
// between them.
final pos = ref.watch(positionProvider).value ?? Duration.zero;
final dur = media.duration ?? Duration.zero;
final dur = displayedMedia.duration ?? Duration.zero;
final isPlaying = playback?.playing == true;
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
final actions = ref.read(playerActionsProvider);
final albumId = (media.extras?['album_id'] as String?) ?? '';
final albumId = (displayedMedia.extras?['album_id'] as String?) ?? '';
// Dominant-color gradient backdrop seeded from the current track's
// album cover. Tweens to the new color on track change via
// AnimatedContainer.
//
// We hold _lastDominant across builds so a track change doesn't
// briefly tween to fs.obsidian while albumColorProvider for the
// new id is loading — that brief stop produced the "background
// coloring snaps in" effect users saw. With a held color, the
// gradient stays on the previous album's color until the new
// extraction resolves, then animates straight to it.
final dominantAsync = ref.watch(albumColorProvider(albumId));
final resolved = dominantAsync.asData?.value;
if (resolved != null) _lastDominant = resolved;
final dominant = _lastDominant ?? fs.obsidian;
// 0.55 alpha keeps the color present without overwhelming contrast
// on the title / artist text below. The lower half of the screen
// stays obsidian for control legibility.
// Backdrop color: render the dominant we've already committed to
// _displayedDominant. AnimatedContainer tweens between successive
// values, so the only color changes the user sees are the
// atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
// the gradient present without overwhelming the title/artist
// text below.
final dominant = _displayedDominant ?? fs.obsidian;
final gradientTop = dominant.withValues(alpha: 0.55);
// Precache the cover image bytes the moment a new MediaItem (with
// a file:// artUri) is broadcast. Without this, AnimatedSwitcher's
// cross-fade for the cover would complete before FileImage finished
// decoding the bytes — visible as a "snap in" of the album art
// after the fade. precacheImage decodes in the background; by the
// time the new _AlbumArt mounts inside the switcher, the bytes are
// ready and the cover paints synchronously.
//
// Non-file artUris (the rare moment before AlbumCoverCache has
// written the file) fall back to ServerImage / CachedNetworkImage
// which has its own 120ms fade — no precache benefit there.
final artUri = media.artUri;
final artKey = artUri?.toString();
if (artUri != null &&
artUri.isScheme('file') &&
artKey != _precachedArtUri) {
_precachedArtUri = artKey;
// ignore: unawaited_futures
precacheImage(FileImage(File.fromUri(artUri)), context);
}
return Scaffold(
backgroundColor: fs.obsidian,
// The whole screen accepts vertical drag for dismissal. Buttons
@@ -167,46 +243,36 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
child: Column(
children: [
const Spacer(),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: KeyedSubtree(
key: ValueKey('cover-${media.id}'),
child: _AlbumArt(
media: media, albumId: albumId, fs: fs),
),
),
// No AnimatedSwitcher: _displayedMedia only
// advances after the preload pipeline has the
// new cover + color ready, so a track change
// is an atomic swap. Fading would just smear a
// transition the user explicitly asked us not
// to add.
_AlbumArt(
media: displayedMedia, albumId: albumId, fs: fs),
const SizedBox(height: 28),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: KeyedSubtree(
key: ValueKey('title-${media.id}'),
child: _TitleRow(media: media, fs: fs),
),
),
_TitleRow(media: displayedMedia, fs: fs),
const SizedBox(height: 4),
AnimatedSwitcher(
duration: _trackChangeDuration,
child: Column(
key: ValueKey('artist-album-${media.id}'),
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayedMedia.artist ?? '',
style: TextStyle(color: fs.ash, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if ((displayedMedia.album ?? '').isNotEmpty) ...[
const SizedBox(height: 2),
Text(
media.artist ?? '',
style: TextStyle(color: fs.ash, fontSize: 14),
displayedMedia.album!,
style: TextStyle(color: fs.ash, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if ((media.album ?? '').isNotEmpty) ...[
const SizedBox(height: 2),
Text(
media.album!,
style: TextStyle(color: fs.ash, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
],
),
const SizedBox(height: 24),
_SecondaryControls(
@@ -214,7 +280,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
actions: actions,
shuffleOn: shuffleOn,
repeatMode: repeatMode,
media: media,
media: displayedMedia,
),
const SizedBox(height: 8),
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
+41 -31
View File
@@ -28,16 +28,37 @@ import 'player_provider.dart';
/// Tap anywhere on the bar (including art/title) ascends to the full
/// player. A vertical drag upward also ascends, so the gesture mirrors
/// the drag-down dismissal on the full screen.
class PlayerBar extends ConsumerWidget {
class PlayerBar extends ConsumerStatefulWidget {
const PlayerBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<PlayerBar> createState() => _PlayerBarState();
}
class _PlayerBarState extends ConsumerState<PlayerBar> {
/// Last non-null artUri we've seen from the mediaItem stream. Held
/// across the audio_handler's two-broadcast track-change pattern
/// (bare MediaItem first, then with artUri once AlbumCoverCache
/// resolves) so the mini bar keeps showing the previous track's
/// cover until the new one is known. Without this hold the bar
/// flickered to the slate placeholder for ~100ms on every track
/// change.
Uri? _lastArtUri;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final media = ref.watch(mediaItemProvider).value;
final playback = ref.watch(playbackStateProvider).value;
if (media == null) return const SizedBox.shrink();
// Capture the latest non-null artUri so _TrackInfo's cover stays
// continuous across track changes.
if (media.artUri != null) _lastArtUri = media.artUri;
final displayMedia = media.artUri == null && _lastArtUri != null
? media.copyWith(artUri: _lastArtUri)
: media;
// positionProvider is just_audio's positionStream (~200ms cadence)
// so the mini bar's seek crawls forward smoothly. PlaybackState
// only updates on event transitions and would leave it frozen.
@@ -65,7 +86,7 @@ class PlayerBar extends ConsumerWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: _TrackInfo(media: media)),
Expanded(child: _TrackInfo(media: displayMedia)),
const SizedBox(width: 8),
_PlayControls(playback: playback, ref: ref),
],
@@ -96,27 +117,27 @@ class _TrackInfo extends StatelessWidget {
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
// so the transition works regardless of what's playing.
//
// AnimatedSwitcher around the cover smooths the artUri null→file://
// swap that fires on every track change: audio_handler broadcasts
// the new MediaItem without artUri immediately, then re-broadcasts
// with artUri once AlbumCoverCache resolves the path. Without the
// crossfade the cover snaps from the previous track's image to a
// slate placeholder to the new image — visible as a flicker.
final Widget coverChild;
if (media.artUri != null) {
// Why no transition / placeholder handling: the audio_handler
// broadcasts MediaItem twice on track change — once with artUri
// null (the new track's bare metadata), then with artUri set once
// AlbumCoverCache resolves. The widget tree above hands us the
// most-recent non-null artUri (`displayArtUri`), so the previous
// track's cover stays visible across that null gap and the new
// cover snaps in the moment its artUri arrives. Rapid change is
// fine here per operator preference; AnimatedSwitcher previously
// smeared the swap and made the slate placeholder visible.
final displayArtUri = media.artUri;
final Widget cover;
if (displayArtUri != null) {
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
// hits the same disk cache the rest of the UI uses (ServerImage,
// discover thumbnails). FileImage stays for the file:// branch
// populated by AlbumCoverCache — bytes are already on disk and a
// second cache layer would only burn duplicate space.
coverChild = Image(
// Key includes the artUri so AnimatedSwitcher detects a swap;
// without this it treats successive Image widgets as the same
// tree node and skips the transition.
key: ValueKey('art-${media.artUri}'),
image: media.artUri!.isScheme('file')
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
: CachedNetworkImageProvider(media.artUri.toString()),
cover = Image(
image: displayArtUri.isScheme('file')
? FileImage(File.fromUri(displayArtUri)) as ImageProvider
: CachedNetworkImageProvider(displayArtUri.toString()),
width: 48,
height: 48,
// Without a fit, Image paints the source at its intrinsic
@@ -128,19 +149,8 @@ class _TrackInfo extends StatelessWidget {
Container(width: 48, height: 48, color: fs.slate),
);
} else {
coverChild = Container(
key: const ValueKey('art-placeholder'),
width: 48,
height: 48,
color: fs.slate,
);
cover = Container(width: 48, height: 48, color: fs.slate);
}
final cover = AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
child: coverChild,
);
return Row(
// Default centering vertically aligns the like/kebab buttons
// against the album art (48dp) — visually they span the title +
+1 -1
View File
@@ -1,7 +1,7 @@
name: minstrel
description: Minstrel mobile client
publish_to: 'none'
version: 2026.5.13+2
version: 2026.5.13+3
environment:
sdk: '>=3.5.0 <4.0.0'