feat(ui): crossfade skeleton into content on extension detail screens

SkeletonCrossfade (AnimatedSwitcher, 250ms) replaces the single-frame
hard swap when the extension album/artist/playlist fetch completes.
This commit is contained in:
zarzet
2026-07-14 09:09:49 +07:00
parent c580280890
commit 8c33a86df5
2 changed files with 79 additions and 51 deletions
+63 -51
View File
@@ -1078,30 +1078,34 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
@override
Widget build(BuildContext context) {
if (_isLoading || _error != null) {
return _LoadingOrErrorScaffold(
title: widget.albumName,
isLoading: _isLoading,
error: _error,
loadingBody: const AlbumTrackListSkeleton(
itemCount: 10,
showCoverHeader: true,
return SkeletonCrossfade(
child: _LoadingOrErrorScaffold(
title: widget.albumName,
isLoading: _isLoading,
error: _error,
loadingBody: const AlbumTrackListSkeleton(
itemCount: 10,
showCoverHeader: true,
),
onRetry: _fetchTracks,
),
onRetry: _fetchTracks,
);
}
return AlbumScreen(
albumId: widget.albumId,
albumName: widget.albumName,
coverUrl: widget.coverUrl,
headerVideoUrl: _headerVideoUrl,
headerImageUrl: _headerImageUrl,
audioTraits: _audioTraits,
tracks: _tracks,
extensionId: widget.extensionId,
artistId: _artistId,
artistName: _artistName,
heroTag: widget.heroTag,
return SkeletonCrossfade(
child: AlbumScreen(
albumId: widget.albumId,
albumName: widget.albumName,
coverUrl: widget.coverUrl,
headerVideoUrl: _headerVideoUrl,
headerImageUrl: _headerImageUrl,
audioTraits: _audioTraits,
tracks: _tracks,
extensionId: widget.extensionId,
artistId: _artistId,
artistName: _artistName,
heroTag: widget.heroTag,
),
);
}
}
@@ -1219,24 +1223,28 @@ class _ExtensionPlaylistScreenState
@override
Widget build(BuildContext context) {
if (_isLoading || _error != null) {
return _LoadingOrErrorScaffold(
title: widget.playlistName,
isLoading: _isLoading,
error: _error,
loadingBody: const TrackListSkeleton(
itemCount: 8,
showCoverHeader: true,
return SkeletonCrossfade(
child: _LoadingOrErrorScaffold(
title: widget.playlistName,
isLoading: _isLoading,
error: _error,
loadingBody: const TrackListSkeleton(
itemCount: 8,
showCoverHeader: true,
),
onRetry: _fetchTracks,
),
onRetry: _fetchTracks,
);
}
return PlaylistScreen(
playlistName: widget.playlistName,
coverUrl: widget.coverUrl,
headerVideoUrl: _headerVideoUrl,
tracks: _tracks!,
recommendedService: widget.extensionId,
return SkeletonCrossfade(
child: PlaylistScreen(
playlistName: widget.playlistName,
coverUrl: widget.coverUrl,
headerVideoUrl: _headerVideoUrl,
tracks: _tracks!,
recommendedService: widget.extensionId,
),
);
}
}
@@ -1376,26 +1384,30 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
@override
Widget build(BuildContext context) {
if (_isLoading || _error != null) {
return _LoadingOrErrorScaffold(
title: widget.artistName,
isLoading: _isLoading,
error: _error,
loadingBody: const ArtistScreenSkeleton(),
onRetry: _fetchArtist,
return SkeletonCrossfade(
child: _LoadingOrErrorScaffold(
title: widget.artistName,
isLoading: _isLoading,
error: _error,
loadingBody: const ArtistScreenSkeleton(),
onRetry: _fetchArtist,
),
);
}
return ArtistScreen(
artistId: widget.artistId,
artistName: widget.artistName,
coverUrl: widget.coverUrl,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
monthlyListeners: _monthlyListeners,
albums: _albums,
topTracks: _topTracks,
extensionId: widget.extensionId, // Skip Spotify/Deezer fetch
heroTag: widget.heroTag,
return SkeletonCrossfade(
child: ArtistScreen(
artistId: widget.artistId,
artistName: widget.artistName,
coverUrl: widget.coverUrl,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
monthlyListeners: _monthlyListeners,
albums: _albums,
topTracks: _topTracks,
extensionId: widget.extensionId, // Skip Spotify/Deezer fetch
heroTag: widget.heroTag,
),
);
}
}
+16
View File
@@ -624,6 +624,22 @@ class HomeSearchSkeleton extends StatelessWidget {
}
}
/// Crossfades when the child's runtime type changes — used to swap a loading
/// skeleton for the loaded screen without a single-frame hard cut.
class SkeletonCrossfade extends StatelessWidget {
final Widget child;
const SkeletonCrossfade({super.key, required this.child});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: child,
);
}
}
/// An animated selection indicator that scales in/out and crossfades the
/// checked/unchecked state.
class AnimatedSelectionCheckbox extends StatelessWidget {