Merge pull request 'release v2026.05.13.2: artist covers + load-then-swap player transitions' (#45) from dev into main
This commit was merged in pull request #45.
This commit is contained in:
+12
-1
@@ -17,10 +17,21 @@ import '../models/track.dart';
|
|||||||
import 'db.dart';
|
import 'db.dart';
|
||||||
|
|
||||||
extension CachedArtistAdapter on CachedArtist {
|
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,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
sortName: sortName,
|
sortName: sortName,
|
||||||
|
coverUrl: coverAlbumId.isNotEmpty
|
||||||
|
? '/api/albums/$coverAlbumId/cover'
|
||||||
|
: '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -18,6 +18,7 @@
|
|||||||
// subscriptions; drift handles this fine in practice but worth
|
// subscriptions; drift handles this fine in practice but worth
|
||||||
// measuring if a future surface scales to hundreds.
|
// 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:drift/drift.dart' show leftOuterJoin;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@ final albumTileProvider =
|
|||||||
|
|
||||||
/// Watches the cached_artists row for [id]. Triggers background
|
/// Watches the cached_artists row for [id]. Triggers background
|
||||||
/// hydration on miss; yields the row once it lands.
|
/// 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 =
|
final artistTileProvider =
|
||||||
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
StreamProvider.family<ArtistRef?, String>((ref, id) async* {
|
||||||
if (id.isEmpty) {
|
if (id.isEmpty) {
|
||||||
@@ -70,7 +77,12 @@ final artistTileProvider =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final db = ref.watch(appDbProvider);
|
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;
|
var enqueued = false;
|
||||||
await for (final rows in query.watch()) {
|
await for (final rows in query.watch()) {
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
@@ -83,7 +95,12 @@ final artistTileProvider =
|
|||||||
yield null;
|
yield null;
|
||||||
continue;
|
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 =
|
final artistProvider =
|
||||||
StreamProvider.family<ArtistRef, String>((ref, id) {
|
StreamProvider.family<ArtistRef, String>((ref, id) {
|
||||||
final db = ref.watch(appDbProvider);
|
final db = ref.watch(appDbProvider);
|
||||||
return cacheFirst<CachedArtist, ArtistRef>(
|
// LEFT JOIN cached_albums for cover-URL reconstruction (see
|
||||||
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
|
// CachedArtistAdapter.toRef). First row carries the alphabetically-
|
||||||
.watch(),
|
// 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 {
|
fetchAndPopulate: () async {
|
||||||
final api = await ref.read(libraryApiProvider.future);
|
final api = await ref.read(libraryApiProvider.future);
|
||||||
final fresh = await api.getArtist(id);
|
final fresh = await api.getArtist(id);
|
||||||
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
|
||||||
},
|
},
|
||||||
toResult: (rows) => rows.isEmpty
|
toResult: (rows) {
|
||||||
? const ArtistRef(id: '', name: '')
|
if (rows.isEmpty) return const ArtistRef(id: '', name: '');
|
||||||
: rows.first.toRef(),
|
final artist = rows.first.readTable(db.cachedArtists);
|
||||||
|
final firstAlbum = rows.first.readTableOrNull(db.cachedAlbums);
|
||||||
|
return artist.toRef(coverAlbumId: firstAlbum?.id ?? '');
|
||||||
|
},
|
||||||
isOnline: () async => (await ref
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
|||||||
@@ -62,9 +62,19 @@ final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|||||||
/// via /api/library/sync.
|
/// via /api/library/sync.
|
||||||
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
final _libraryArtistsProvider = StreamProvider<List<ArtistRef>>((ref) {
|
||||||
final db = ref.watch(appDbProvider);
|
final db = ref.watch(appDbProvider);
|
||||||
final query = db.select(db.cachedArtists)
|
// LEFT JOIN cached_albums so each artist row carries a representative
|
||||||
..orderBy([(t) => drift.OrderingTerm.asc(t.sortName)]);
|
// album id for cover-URL reconstruction. Sorted by artist sortName
|
||||||
return cacheFirst<CachedArtist, List<ArtistRef>>(
|
// 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(),
|
driftStream: query.watch(),
|
||||||
fetchAndPopulate: () async {
|
fetchAndPopulate: () async {
|
||||||
// Cold-cache fallback: sync should have populated drift already,
|
// 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
|
isOnline: () async => (await ref
|
||||||
.read(connectivityProvider.future)
|
.read(connectivityProvider.future)
|
||||||
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
|
||||||
|
|||||||
@@ -45,16 +45,72 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
// dismiss. Reset on each drag start.
|
// dismiss. Reset on each drag start.
|
||||||
double _dragOffset = 0;
|
double _dragOffset = 0;
|
||||||
|
|
||||||
/// Last resolved dominant color. Held across track changes so the
|
/// The MediaItem currently displayed on the screen. Held separately
|
||||||
/// gradient backdrop tweens smoothly from the previous album's color
|
/// from the live mediaItemProvider so we can preload the new track's
|
||||||
/// to the next, instead of dropping through fs.obsidian during the
|
/// cover bytes + dominant color BEFORE flipping the visible state.
|
||||||
/// brief moment albumColorProvider for the new id is still loading.
|
/// Without this gate the full-player UI swapped to the new track's
|
||||||
Color? _lastDominant;
|
/// 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
|
/// Dominant color of [_displayedMedia]'s cover. Tweened by the
|
||||||
/// fire precacheImage on every MediaItem rebroadcast (twice per track
|
/// backdrop AnimatedContainer when it changes.
|
||||||
/// change, once on artUri arrival).
|
Color? _displayedDominant;
|
||||||
String? _precachedArtUri;
|
|
||||||
|
/// 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 _) {
|
void _onDragStart(DragStartDetails _) {
|
||||||
_dragOffset = 0;
|
_dragOffset = 0;
|
||||||
@@ -78,10 +134,61 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
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.')));
|
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
|
// only fires on state transitions and would leave the bar frozen
|
||||||
// between them.
|
// between them.
|
||||||
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
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 isPlaying = playback?.playing == true;
|
||||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||||
final actions = ref.read(playerActionsProvider);
|
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
|
// Backdrop color: render the dominant we've already committed to
|
||||||
// album cover. Tweens to the new color on track change via
|
// _displayedDominant. AnimatedContainer tweens between successive
|
||||||
// AnimatedContainer.
|
// values, so the only color changes the user sees are the
|
||||||
//
|
// atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
|
||||||
// We hold _lastDominant across builds so a track change doesn't
|
// the gradient present without overwhelming the title/artist
|
||||||
// briefly tween to fs.obsidian while albumColorProvider for the
|
// text below.
|
||||||
// new id is loading — that brief stop produced the "background
|
final dominant = _displayedDominant ?? fs.obsidian;
|
||||||
// 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.
|
|
||||||
final gradientTop = dominant.withValues(alpha: 0.55);
|
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(
|
return Scaffold(
|
||||||
backgroundColor: fs.obsidian,
|
backgroundColor: fs.obsidian,
|
||||||
// The whole screen accepts vertical drag for dismissal. Buttons
|
// The whole screen accepts vertical drag for dismissal. Buttons
|
||||||
@@ -167,46 +243,36 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
AnimatedSwitcher(
|
// No AnimatedSwitcher: _displayedMedia only
|
||||||
duration: _trackChangeDuration,
|
// advances after the preload pipeline has the
|
||||||
child: KeyedSubtree(
|
// new cover + color ready, so a track change
|
||||||
key: ValueKey('cover-${media.id}'),
|
// is an atomic swap. Fading would just smear a
|
||||||
child: _AlbumArt(
|
// transition the user explicitly asked us not
|
||||||
media: media, albumId: albumId, fs: fs),
|
// to add.
|
||||||
),
|
_AlbumArt(
|
||||||
),
|
media: displayedMedia, albumId: albumId, fs: fs),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
AnimatedSwitcher(
|
_TitleRow(media: displayedMedia, fs: fs),
|
||||||
duration: _trackChangeDuration,
|
|
||||||
child: KeyedSubtree(
|
|
||||||
key: ValueKey('title-${media.id}'),
|
|
||||||
child: _TitleRow(media: media, fs: fs),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
AnimatedSwitcher(
|
Column(
|
||||||
duration: _trackChangeDuration,
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
children: [
|
||||||
key: ValueKey('artist-album-${media.id}'),
|
Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
displayedMedia.artist ?? '',
|
||||||
children: [
|
style: TextStyle(color: fs.ash, fontSize: 14),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if ((displayedMedia.album ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
media.artist ?? '',
|
displayedMedia.album!,
|
||||||
style: TextStyle(color: fs.ash, fontSize: 14),
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
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),
|
const SizedBox(height: 24),
|
||||||
_SecondaryControls(
|
_SecondaryControls(
|
||||||
@@ -214,7 +280,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|||||||
actions: actions,
|
actions: actions,
|
||||||
shuffleOn: shuffleOn,
|
shuffleOn: shuffleOn,
|
||||||
repeatMode: repeatMode,
|
repeatMode: repeatMode,
|
||||||
media: media,
|
media: displayedMedia,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
||||||
|
|||||||
@@ -28,16 +28,37 @@ import 'player_provider.dart';
|
|||||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||||
/// the drag-down dismissal on the full screen.
|
/// the drag-down dismissal on the full screen.
|
||||||
class PlayerBar extends ConsumerWidget {
|
class PlayerBar extends ConsumerStatefulWidget {
|
||||||
const PlayerBar({super.key});
|
const PlayerBar({super.key});
|
||||||
|
|
||||||
@override
|
@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 fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||||
final media = ref.watch(mediaItemProvider).value;
|
final media = ref.watch(mediaItemProvider).value;
|
||||||
final playback = ref.watch(playbackStateProvider).value;
|
final playback = ref.watch(playbackStateProvider).value;
|
||||||
if (media == null) return const SizedBox.shrink();
|
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)
|
// positionProvider is just_audio's positionStream (~200ms cadence)
|
||||||
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
// so the mini bar's seek crawls forward smoothly. PlaybackState
|
||||||
// only updates on event transitions and would leave it frozen.
|
// only updates on event transitions and would leave it frozen.
|
||||||
@@ -65,7 +86,7 @@ class PlayerBar extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _TrackInfo(media: media)),
|
Expanded(child: _TrackInfo(media: displayMedia)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_PlayControls(playback: playback, ref: ref),
|
_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)
|
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
|
||||||
// so the transition works regardless of what's playing.
|
// so the transition works regardless of what's playing.
|
||||||
//
|
//
|
||||||
// AnimatedSwitcher around the cover smooths the artUri null→file://
|
// Why no transition / placeholder handling: the audio_handler
|
||||||
// swap that fires on every track change: audio_handler broadcasts
|
// broadcasts MediaItem twice on track change — once with artUri
|
||||||
// the new MediaItem without artUri immediately, then re-broadcasts
|
// null (the new track's bare metadata), then with artUri set once
|
||||||
// with artUri once AlbumCoverCache resolves the path. Without the
|
// AlbumCoverCache resolves. The widget tree above hands us the
|
||||||
// crossfade the cover snaps from the previous track's image to a
|
// most-recent non-null artUri (`displayArtUri`), so the previous
|
||||||
// slate placeholder to the new image — visible as a flicker.
|
// track's cover stays visible across that null gap and the new
|
||||||
final Widget coverChild;
|
// cover snaps in the moment its artUri arrives. Rapid change is
|
||||||
if (media.artUri != null) {
|
// 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
|
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
|
||||||
// hits the same disk cache the rest of the UI uses (ServerImage,
|
// hits the same disk cache the rest of the UI uses (ServerImage,
|
||||||
// discover thumbnails). FileImage stays for the file:// branch
|
// discover thumbnails). FileImage stays for the file:// branch
|
||||||
// populated by AlbumCoverCache — bytes are already on disk and a
|
// populated by AlbumCoverCache — bytes are already on disk and a
|
||||||
// second cache layer would only burn duplicate space.
|
// second cache layer would only burn duplicate space.
|
||||||
coverChild = Image(
|
cover = Image(
|
||||||
// Key includes the artUri so AnimatedSwitcher detects a swap;
|
image: displayArtUri.isScheme('file')
|
||||||
// without this it treats successive Image widgets as the same
|
? FileImage(File.fromUri(displayArtUri)) as ImageProvider
|
||||||
// tree node and skips the transition.
|
: CachedNetworkImageProvider(displayArtUri.toString()),
|
||||||
key: ValueKey('art-${media.artUri}'),
|
|
||||||
image: media.artUri!.isScheme('file')
|
|
||||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
|
||||||
: CachedNetworkImageProvider(media.artUri.toString()),
|
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
// Without a fit, Image paints the source at its intrinsic
|
// 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),
|
Container(width: 48, height: 48, color: fs.slate),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
coverChild = Container(
|
cover = Container(width: 48, height: 48, color: fs.slate);
|
||||||
key: const ValueKey('art-placeholder'),
|
|
||||||
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(
|
return Row(
|
||||||
// Default centering vertically aligns the like/kebab buttons
|
// Default centering vertically aligns the like/kebab buttons
|
||||||
// against the album art (48dp) — visually they span the title +
|
// against the album art (48dp) — visually they span the title +
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: minstrel
|
name: minstrel
|
||||||
description: Minstrel mobile client
|
description: Minstrel mobile client
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2026.5.13+2
|
version: 2026.5.13+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.5.0 <4.0.0'
|
sdk: '>=3.5.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user