diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart index 92b6151d..ff1ea8a2 100644 --- a/flutter_client/lib/discover/discover_screen.dart +++ b/flutter_client/lib/discover/discover_screen.dart @@ -208,7 +208,7 @@ class _ResultTile extends StatelessWidget { : CachedNetworkImage( imageUrl: row.imageUrl, fit: BoxFit.cover, - fadeInDuration: Duration.zero, + fadeInDuration: const Duration(milliseconds: 120), fadeOutDuration: Duration.zero, errorWidget: (_, __, ___) => Icon(Icons.album, color: fs.ash), diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index c8f01a74..968ed772 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -83,6 +83,13 @@ class HomeScreen extends ConsumerWidget { // ─── Per-tile widgets ──────────────────────────────────────────────── +/// Duration of the skeleton→content cross-fade. 220ms reads as "tile +/// settled into place" — longer drags, shorter feels like a hard cut. +/// Each tile cross-fades independently when its data lands, so the +/// natural cascade from the hydration queue's bounded concurrency +/// produces a staged-reveal feel without any per-tile delay math. +const Duration _tileRevealDuration = Duration(milliseconds: 220); + /// Album tile: skeleton until albumTileProvider yields a populated row. class _AlbumTile extends ConsumerWidget { const _AlbumTile({required this.id}); @@ -92,10 +99,17 @@ class _AlbumTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final asyncAlbum = ref.watch(albumTileProvider(id)); final album = asyncAlbum.asData?.value; - if (album == null) return const SkeletonAlbumTile(); - return AlbumCard( - album: album, - onTap: () => context.push('/albums/${album.id}', extra: album), + return AnimatedSwitcher( + duration: _tileRevealDuration, + switchInCurve: Curves.easeOut, + child: album == null + ? const SkeletonAlbumTile(key: ValueKey('skeleton')) + : AlbumCard( + key: ValueKey('album-${album.id}'), + album: album, + onTap: () => + context.push('/albums/${album.id}', extra: album), + ), ); } } @@ -109,10 +123,17 @@ class _ArtistTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final asyncArtist = ref.watch(artistTileProvider(id)); final artist = asyncArtist.asData?.value; - if (artist == null) return const SkeletonArtistTile(); - return ArtistCard( - artist: artist, - onTap: () => context.push('/artists/${artist.id}', extra: artist), + return AnimatedSwitcher( + duration: _tileRevealDuration, + switchInCurve: Curves.easeOut, + child: artist == null + ? const SkeletonArtistTile(key: ValueKey('skeleton')) + : ArtistCard( + key: ValueKey('artist-${artist.id}'), + artist: artist, + onTap: () => + context.push('/artists/${artist.id}', extra: artist), + ), ); } } @@ -131,15 +152,18 @@ class _TrackTile extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final asyncTrack = ref.watch(trackTileProvider(id)); final track = asyncTrack.asData?.value; - if (track == null) { - // Compact-track placeholder: same 56dp footprint as the real card - // so the row's intrinsic width doesn't change as tiles hydrate. - return const _CompactTrackSkeleton(); - } - return CompactTrackCard( - track: track, - sectionTracks: _resolveSectionTracks(ref, sectionIds), - index: sectionIds.indexOf(id).clamp(0, sectionIds.length - 1), + return AnimatedSwitcher( + duration: _tileRevealDuration, + switchInCurve: Curves.easeOut, + child: track == null + ? const _CompactTrackSkeleton(key: ValueKey('skeleton')) + : CompactTrackCard( + key: ValueKey('track-${track.id}'), + track: track, + sectionTracks: _resolveSectionTracks(ref, sectionIds), + index: + sectionIds.indexOf(id).clamp(0, sectionIds.length - 1), + ), ); } @@ -161,7 +185,7 @@ class _TrackTile extends ConsumerWidget { /// CompactTrackCard so swapping in the real card doesn't shift the /// row's height. class _CompactTrackSkeleton extends StatelessWidget { - const _CompactTrackSkeleton(); + const _CompactTrackSkeleton({super.key}); @override Widget build(BuildContext context) { final fs = Theme.of(context).extension()!; diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 43712ea6..33ceb20a 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -631,6 +631,11 @@ class _LikedTab extends ConsumerWidget { } } +/// Skeleton→content cross-fade duration. Matches home_screen so the +/// reveal feel is consistent across surfaces. See _tileRevealDuration +/// in home_screen.dart for the rationale. +const Duration _likedTileReveal = Duration(milliseconds: 220); + /// Liked-Artists carousel tile. Skeleton until artistTileProvider /// yields a populated row. class _LikedArtistTile extends ConsumerWidget { @@ -640,10 +645,17 @@ class _LikedArtistTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final artist = ref.watch(artistTileProvider(id)).asData?.value; - if (artist == null) return const SkeletonArtistTile(); - return ArtistCard( - artist: artist, - onTap: () => context.push('/artists/${artist.id}', extra: artist), + return AnimatedSwitcher( + duration: _likedTileReveal, + switchInCurve: Curves.easeOut, + child: artist == null + ? const SkeletonArtistTile(key: ValueKey('skeleton')) + : ArtistCard( + key: ValueKey('artist-${artist.id}'), + artist: artist, + onTap: () => + context.push('/artists/${artist.id}', extra: artist), + ), ); } } @@ -657,10 +669,16 @@ class _LikedAlbumTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final album = ref.watch(albumTileProvider(id)).asData?.value; - if (album == null) return const SkeletonAlbumTile(); - return AlbumCard( - album: album, - onTap: () => context.push('/albums/${album.id}', extra: album), + return AnimatedSwitcher( + duration: _likedTileReveal, + switchInCurve: Curves.easeOut, + child: album == null + ? const SkeletonAlbumTile(key: ValueKey('skeleton')) + : AlbumCard( + key: ValueKey('album-${album.id}'), + album: album, + onTap: () => context.push('/albums/${album.id}', extra: album), + ), ); } } @@ -677,21 +695,27 @@ class _LikedTrackRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final track = ref.watch(trackTileProvider(id)).asData?.value; - if (track == null) return const SkeletonTrackRow(); - return TrackRow( - track: track, - onTap: () { - final hydrated = []; - for (final i in sectionIds) { - final v = ref.read(trackTileProvider(i)).asData?.value; - if (v != null) hydrated.add(v); - } - final start = hydrated.indexWhere((t) => t.id == id); - ref.read(playerActionsProvider).playTracks( - hydrated, - initialIndex: start < 0 ? 0 : start, - ); - }, + return AnimatedSwitcher( + duration: _likedTileReveal, + switchInCurve: Curves.easeOut, + child: track == null + ? const SkeletonTrackRow(key: ValueKey('skeleton')) + : TrackRow( + key: ValueKey('track-${track.id}'), + track: track, + onTap: () { + final hydrated = []; + for (final i in sectionIds) { + final v = ref.read(trackTileProvider(i)).asData?.value; + if (v != null) hydrated.add(v); + } + final start = hydrated.indexWhere((t) => t.id == id); + ref.read(playerActionsProvider).playTracks( + hydrated, + initialIndex: start < 0 ? 0 : start, + ); + }, + ), ); } } diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index 9b4262a7..50c4d12b 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -67,16 +67,23 @@ class PlaylistDetailScreen extends ConsumerWidget { overflow: TextOverflow.ellipsis, ), ), - body: detail.when( - // Loading from a seed: render the header immediately + the - // exact track-count of skeleton rows, instead of an opaque - // full-screen spinner. Cold-visit feel: shaped page with - // shimmering rows that swap in for real ones as the bulk - // detail fetch lands. - loading: () => _SkeletonBody(seed: seed), - error: (e, _) => - Center(child: Text('$e', style: TextStyle(color: fs.error))), - data: (d) => _Body(detail: d), + // AnimatedSwitcher between the skeleton body and the real body + // smooths the cold-visit moment when bulk detail lands. 220ms + // matches the per-tile reveal feel used on home / liked tabs. + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + child: detail.when( + loading: () => _SkeletonBody( + key: const ValueKey('skeleton'), + seed: seed, + ), + error: (e, _) => Center( + key: const ValueKey('error'), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (d) => _Body(key: const ValueKey('body'), detail: d), + ), ), ); } @@ -88,7 +95,7 @@ class PlaylistDetailScreen extends ConsumerWidget { /// (deep link straight to a playlist with no prior cache) the /// skeleton renders a small default and grows when real data arrives. class _SkeletonBody extends StatelessWidget { - const _SkeletonBody({this.seed}); + const _SkeletonBody({super.key, this.seed}); final Playlist? seed; static const _defaultSkeletonCount = 8; @@ -163,7 +170,7 @@ class _PlaylistTrackSkeleton extends StatelessWidget { } class _Body extends ConsumerWidget { - const _Body({required this.detail}); + const _Body({super.key, required this.detail}); final PlaylistDetail detail; @override diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart index e3080588..88d07c4d 100644 --- a/flutter_client/lib/shared/widgets/server_image.dart +++ b/flutter_client/lib/shared/widgets/server_image.dart @@ -61,10 +61,12 @@ class ServerImage extends ConsumerWidget { imageUrl: resolved, httpHeaders: headers, fit: fit, - // No fadeIn — covers paint instantly once cached, and the - // default 500ms fade looks like a regression on a populated - // grid. - fadeInDuration: Duration.zero, + // 120ms feels like cover bytes settling in on a cache miss + // (smoother than the abrupt zero-fade); on cache hits the + // image is decoded synchronously so the fade is imperceptible. + // The default 500ms is too long — looks like a regression on + // a populated grid. + fadeInDuration: const Duration(milliseconds: 120), fadeOutDuration: Duration.zero, // Keep failures local — a single 401/timeout shouldn't dump // a stack trace or replace the parent Container's background.