feat(artists): add branded headers and incremental release loading

This commit is contained in:
zarzet
2026-09-20 11:46:16 +07:00
parent e61ff64adc
commit dc25370350
11 changed files with 556 additions and 145 deletions
+12
View File
@@ -25,6 +25,8 @@ class TrackState {
final String? coverUrl;
final String? headerImageUrl;
final String? headerVideoUrl;
final String? headerLogoUrl;
final String? artistAlbumsNext;
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks;
@@ -47,6 +49,8 @@ class TrackState {
this.coverUrl,
this.headerImageUrl,
this.headerVideoUrl,
this.headerLogoUrl,
this.artistAlbumsNext,
this.monthlyListeners,
this.artistAlbums,
this.artistTopTracks,
@@ -72,6 +76,8 @@ class TrackState {
String? coverUrl,
String? headerImageUrl,
String? headerVideoUrl,
String? headerLogoUrl,
String? artistAlbumsNext,
int? monthlyListeners,
List<ArtistAlbum>? artistAlbums,
List<Track>? artistTopTracks,
@@ -96,6 +102,8 @@ class TrackState {
coverUrl: coverUrl ?? this.coverUrl,
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
headerVideoUrl: headerVideoUrl ?? this.headerVideoUrl,
headerLogoUrl: headerLogoUrl ?? this.headerLogoUrl,
artistAlbumsNext: artistAlbumsNext ?? this.artistAlbumsNext,
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
artistAlbums: artistAlbums ?? this.artistAlbums,
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
@@ -290,7 +298,11 @@ class TrackNotifier extends Notifier<TrackState> {
headerVideoUrl: normalizeRemoteHttpUrl(
artistData['header_video']?.toString(),
),
headerLogoUrl: normalizeRemoteHttpUrl(
artistData['header_logo']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?,
artistAlbumsNext: artistData['albums_next'] as String?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
searchExtensionId: extensionId,
+225 -83
View File
@@ -62,6 +62,8 @@ class _ArtistCache {
List<Track>? topTracks,
String? headerImageUrl,
String? headerVideoUrl,
String? headerLogoUrl,
String? albumsNext,
int? monthlyListeners,
}) {
_cache.set(
@@ -72,6 +74,8 @@ class _ArtistCache {
topTracks: topTracks,
headerImageUrl: headerImageUrl,
headerVideoUrl: headerVideoUrl,
headerLogoUrl: headerLogoUrl,
albumsNext: albumsNext,
monthlyListeners: monthlyListeners,
),
);
@@ -84,6 +88,8 @@ class _CacheEntry {
final List<Track>? topTracks;
final String? headerImageUrl;
final String? headerVideoUrl;
final String? headerLogoUrl;
final String? albumsNext;
final int? monthlyListeners;
_CacheEntry({
@@ -92,6 +98,8 @@ class _CacheEntry {
this.topTracks,
this.headerImageUrl,
this.headerVideoUrl,
this.headerLogoUrl,
this.albumsNext,
this.monthlyListeners,
});
}
@@ -102,6 +110,8 @@ class ArtistScreen extends ConsumerStatefulWidget {
final String? coverUrl;
final String? headerImageUrl;
final String? headerVideoUrl;
final String? headerLogoUrl;
final String? albumsNext;
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
@@ -114,6 +124,8 @@ class ArtistScreen extends ConsumerStatefulWidget {
this.coverUrl,
this.headerImageUrl,
this.headerVideoUrl,
this.headerLogoUrl,
this.albumsNext,
this.monthlyListeners,
this.albums,
this.topTracks,
@@ -133,6 +145,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
List<Track>? _topTracks;
String? _headerImageUrl;
String? _headerVideoUrl;
String? _headerLogoUrl;
String? _albumsNext;
bool _isLoadingMoreAlbums = false;
String? _albumsPageError;
DateTime? _albumPageRetryAfter;
int? _monthlyListeners;
String? _error;
@@ -226,6 +243,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_headerLogoUrl = widget.headerLogoUrl;
_albumsNext = widget.albumsNext;
_monthlyListeners = widget.monthlyListeners;
if ((_albums == null || _albums!.isEmpty) ||
@@ -242,6 +261,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_headerLogoUrl = widget.headerLogoUrl;
_albumsNext = widget.albumsNext;
_monthlyListeners = widget.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
@@ -253,6 +274,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_headerVideoUrl = cached.headerVideoUrl;
_headerLogoUrl = cached.headerLogoUrl;
_albumsNext = cached.albumsNext;
_monthlyListeners = cached.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
@@ -290,6 +313,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
List<Track>? topTracks;
String? headerImage;
String? headerVideo;
String? headerLogo;
String? albumsNext;
int? listeners;
if (_directMetadataProviderId() != null) {
@@ -319,6 +344,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
}
final artistInfo = artistData['artist_info'] as Map<String, dynamic>?;
albumsNext =
(artistInfo?['albums_next'] ?? artistData['albums_next'])
as String?;
headerImage =
artistInfo?['images'] as String? ??
artistInfo?['header_image'] as String? ??
@@ -329,6 +357,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
headerVideo =
artistInfo?['header_video'] as String? ??
artistData['header_video'] as String?;
headerLogo = normalizeRemoteHttpUrl(
(artistInfo?['header_logo'] ?? artistData['header_logo'])?.toString(),
);
listeners =
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
} else {
@@ -337,6 +368,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
if (result != null && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
albumsNext = artistData['albums_next'] as String?;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
@@ -352,6 +384,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
headerImage = artistData['header_image'] as String?;
headerVideo = artistData['header_video'] as String?;
headerLogo = normalizeRemoteHttpUrl(
artistData['header_logo']?.toString(),
);
listeners = artistData['listeners'] as int?;
} else {
throw StateError('Failed to load artist metadata from extension');
@@ -362,6 +397,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalHeaderVideo =
headerVideo ?? _headerVideoUrl ?? widget.headerVideoUrl;
final finalHeaderLogo =
headerLogo ?? _headerLogoUrl ?? widget.headerLogoUrl;
final finalListeners =
listeners ?? _monthlyListeners ?? widget.monthlyListeners;
@@ -372,6 +409,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
headerVideoUrl: finalHeaderVideo,
headerLogoUrl: finalHeaderLogo,
albumsNext: albumsNext,
monthlyListeners: finalListeners,
);
@@ -382,6 +421,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_headerVideoUrl = finalHeaderVideo;
_headerLogoUrl = finalHeaderLogo;
_albumsNext = albumsNext;
_albumsPageError = null;
_monthlyListeners = finalListeners;
_error = null;
_isLoadingDiscography = false;
@@ -397,6 +439,82 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
}
}
bool _onAlbumsScroll(ScrollNotification notification) {
final advancing = switch (notification) {
ScrollUpdateNotification() => (notification.scrollDelta ?? 0) > 0,
OverscrollNotification() => notification.overscroll > 0,
_ => false,
};
if (advancing &&
notification.metrics.extentAfter < 480 &&
!_isLoadingDiscography &&
!isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadMoreAlbums();
});
}
return false;
}
Future<void> _loadMoreAlbums() async {
final next = _albumsNext;
final provider = _directMetadataProviderId();
if (_isLoadingMoreAlbums ||
next == null ||
next.isEmpty ||
provider == null ||
(_albumPageRetryAfter?.isAfter(DateTime.now()) ?? false)) {
return;
}
setState(() {
_isLoadingMoreAlbums = true;
_albumsPageError = null;
});
try {
final result = await PlatformBridge.getProviderMetadata(
provider,
'artist',
next,
);
if (!mounted) return;
final info = result['artist_info'] as Map<String, dynamic>? ?? result;
final page = (result['albums'] as List<dynamic>? ?? []).map(
(value) => _parseArtistAlbum(value as Map<String, dynamic>),
);
final merged = {
for (final album in _albums ?? <ArtistAlbum>[]) album.id: album,
};
for (final album in page) {
merged[album.id] = album;
}
final following = info['albums_next'] as String?;
setState(() {
_albums = merged.values.toList();
_albumsNext = following == next ? null : following;
});
_ArtistCache.set(
widget.artistId,
albums: _albums!,
releases: _releases,
topTracks: _topTracks,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
headerLogoUrl: _headerLogoUrl,
monthlyListeners: _monthlyListeners,
albumsNext: _albumsNext,
);
} catch (error) {
if (mounted) {
setState(() {
_albumsPageError = context.friendlyError(error);
_albumPageRetryAfter = DateTime.now().add(const Duration(seconds: 3));
});
}
} finally {
if (mounted) setState(() => _isLoadingMoreAlbums = false);
}
}
Track _parseTrack(Map<String, dynamic> data, {ArtistAlbum? album}) {
int durationMs = 0;
final durationValue = data['duration_ms'];
@@ -526,93 +644,117 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
..._buildHeader(
context,
colorScheme,
albums: albums,
hasDiscography: hasDiscography,
),
if (_isLoadingDiscography)
SliverToBoxAdapter(
child: ArtistScreenSkeleton(
showCoverHeader:
!context.isMornye &&
(_headerImageUrl ??
widget.headerImageUrl ??
widget.coverUrl) ==
null,
showPopularSection:
!widget.artistId.startsWith('deezer:') &&
!widget.artistId.startsWith('qobuz:') &&
!widget.artistId.startsWith('tidal:'),
),
NotificationListener<ScrollNotification>(
onNotification: (notification) => notification.depth == 0
? _onAlbumsScroll(notification)
: false,
child: CustomScrollView(
controller: _scrollController,
slivers: [
..._buildHeader(
context,
colorScheme,
albums: albums,
hasDiscography: hasDiscography,
),
if (_error != null)
if (_isLoadingDiscography)
SliverToBoxAdapter(
child: ArtistScreenSkeleton(
showCoverHeader:
!context.isMornye &&
(_headerImageUrl ??
widget.headerImageUrl ??
widget.coverUrl) ==
null,
showPopularSection:
!widget.artistId.startsWith('deezer:') &&
!widget.artistId.startsWith('qobuz:') &&
!widget.artistId.startsWith('tidal:'),
),
),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ErrorCard(
error: _error!,
colorScheme: colorScheme,
onRetry: _fetchDiscography,
),
),
),
if (!_isLoadingDiscography && _error == null) ...[
if (context.isMornye &&
albums.isNotEmpty &&
!isSelectionMode)
SliverToBoxAdapter(
child: _buildMornyeFeaturedAlbum(context, albums.first),
),
if (_topTracks != null && _topTracks!.isNotEmpty)
SliverToBoxAdapter(
child: _buildPopularSection(context, colorScheme),
),
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistReleases,
releases,
colorScheme,
),
),
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistAlbums,
albumsOnly,
colorScheme,
),
),
if (singles.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistSingles,
singles,
colorScheme,
showTypeBadge: true,
),
),
if (compilations.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistCompilations,
compilations,
colorScheme,
),
),
],
if (_isLoadingMoreAlbums || _albumsPageError != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (_albumsPageError != null)
Text(
_albumsPageError!,
textAlign: TextAlign.center,
),
if (_isLoadingMoreAlbums)
const CircularProgressIndicator.adaptive(),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ErrorCard(
error: _error!,
colorScheme: colorScheme,
onRetry: _fetchDiscography,
),
),
child: SizedBox(height: isSelectionMode ? 120 : 32),
),
if (!_isLoadingDiscography && _error == null) ...[
if (context.isMornye && albums.isNotEmpty && !isSelectionMode)
SliverToBoxAdapter(
child: _buildMornyeFeaturedAlbum(context, albums.first),
),
if (_topTracks != null && _topTracks!.isNotEmpty)
SliverToBoxAdapter(
child: _buildPopularSection(context, colorScheme),
),
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistReleases,
releases,
colorScheme,
),
),
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistAlbums,
albumsOnly,
colorScheme,
),
),
if (singles.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistSingles,
singles,
colorScheme,
showTypeBadge: true,
),
),
if (compilations.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistCompilations,
compilations,
colorScheme,
),
),
const NavBarSliverSpacer(),
],
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 120 : 32),
),
const NavBarSliverSpacer(),
],
),
),
],
),
+33 -26
View File
@@ -67,6 +67,7 @@ extension _ArtistScreenSections on _ArtistScreenState {
: const Center(child: Icon(CupertinoIcons.person, size: 80));
return MornyeArtistHeader(
name: widget.artistName,
logoUrl: _headerLogoUrl ?? widget.headerLogoUrl,
listeners: listenersText,
showTitle: _showTitleInAppBar,
artwork: hasMotionBanner
@@ -79,9 +80,9 @@ extension _ArtistScreenSections on _ArtistScreenState {
icon: isFavoriteArtist
? CupertinoIcons.heart_fill
: CupertinoIcons.heart,
iconColor: isFavoriteArtist
? colorScheme.primary
: Colors.white,
iconColor: colorScheme.primary,
glassTintColor: Colors.white,
glassTintOpacity: 0.10,
tooltip: isFavoriteArtist
? context.l10n.artistOptionRemoveFromFavorites
: context.l10n.artistOptionAddToFavorites,
@@ -95,23 +96,26 @@ extension _ArtistScreenSections on _ArtistScreenState {
CupertinoButton(
padding: EdgeInsets.zero,
borderRadius: BorderRadius.circular(30),
color: Colors.white,
color: colorScheme.primary,
onPressed: () =>
_showDiscographyOptions(context, colorScheme, albums),
child: SizedBox.square(
dimension: 60,
child: Semantics(
label: context.l10n.discographyDownload,
child: const Icon(
child: Icon(
CupertinoIcons.arrow_down,
size: 26,
color: Colors.black,
color: colorScheme.onPrimary,
),
),
),
),
HeaderCircleButton(
icon: CupertinoIcons.ellipsis,
iconColor: colorScheme.primary,
glassTintColor: Colors.white,
glassTintOpacity: 0.10,
tooltip: context.l10n.openInOtherServices,
onPressed: () => _showShareSheet(context),
),
@@ -795,26 +799,29 @@ extension _ArtistScreenSections on _ArtistScreenState {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(
height: sectionHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(
context,
album,
colorScheme,
tileSize: tileSize,
sectionHeight: sectionHeight,
showTypeBadge: showTypeBadge,
),
);
},
NotificationListener<ScrollNotification>(
onNotification: _onAlbumsScroll,
child: SizedBox(
height: sectionHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(
context,
album,
colorScheme,
tileSize: tileSize,
sectionHeight: sectionHeight,
showTypeBadge: showTypeBadge,
),
);
},
),
),
),
],
+2
View File
@@ -653,6 +653,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
coverUrl: trackState.coverUrl,
headerImageUrl: trackState.headerImageUrl,
headerVideoUrl: trackState.headerVideoUrl,
headerLogoUrl: trackState.headerLogoUrl,
albumsNext: trackState.artistAlbumsNext,
albums: trackState.artistAlbums!,
extensionId: extensionId,
),
+9
View File
@@ -1335,6 +1335,8 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
List<Track>? _topTracks;
String? _headerImageUrl;
String? _headerVideoUrl;
String? _headerLogoUrl;
String? _albumsNext;
int? _monthlyListeners;
bool _isLoading = true;
String? _error;
@@ -1392,12 +1394,17 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
result['header_video'] as String?;
final listeners =
artistInfo['listeners'] as int? ?? result['listeners'] as int?;
final headerLogo = normalizeRemoteHttpUrl(
(artistInfo['header_logo'] ?? result['header_logo'])?.toString(),
);
setState(() {
_albums = albums;
_topTracks = topTracks;
_headerImageUrl = headerImage;
_headerVideoUrl = headerVideo;
_headerLogoUrl = headerLogo;
_albumsNext = artistInfo['albums_next'] as String?;
_monthlyListeners = listeners;
_isLoading = false;
});
@@ -1463,6 +1470,8 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen>
coverUrl: widget.coverUrl,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
headerLogoUrl: _headerLogoUrl,
albumsNext: _albumsNext,
monthlyListeners: _monthlyListeners,
albums: _albums,
topTracks: _topTracks,
+14 -4
View File
@@ -566,6 +566,8 @@ class HeaderCircleButton extends ConsumerWidget {
required this.onPressed,
this.iconColor,
this.tonal = false,
this.glassTintColor,
this.glassTintOpacity,
});
final IconData icon;
@@ -578,6 +580,10 @@ class HeaderCircleButton extends ConsumerWidget {
/// Uses the same translucent fill as the local album's Play/Shuffle pills.
final bool tonal;
/// Optional tint for controls floating over artwork-colored surfaces.
final Color? glassTintColor;
final double? glassTintOpacity;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tokens = context.tokens;
@@ -622,10 +628,14 @@ class HeaderCircleButton extends ConsumerWidget {
widthFactor: 1,
child: MornyeGlass.navigation(
radius: 24,
tintColor: scheme.brightness == Brightness.dark
? Colors.black
: Colors.white,
tintOpacity: scheme.brightness == Brightness.dark ? 0.45 : 0.55,
tintColor:
glassTintColor ??
(scheme.brightness == Brightness.dark
? Colors.black
: Colors.white),
tintOpacity:
glassTintOpacity ??
(scheme.brightness == Brightness.dark ? 0.45 : 0.55),
blurEnabled: blurEnabled,
child: button,
),
+115 -30
View File
@@ -1,4 +1,9 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
@@ -61,8 +66,8 @@ class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
data: darkTheme.copyWith(
scaffoldBackgroundColor: surface,
colorScheme: darkTheme.colorScheme.copyWith(
primary: widget.neutralActions ? Colors.white : null,
onPrimary: widget.neutralActions ? surface : null,
primary: widget.neutralActions ? Colors.white : palette.primary,
onPrimary: widget.neutralActions ? surface : palette.onPrimary,
onSurfaceVariant: widget.neutralActions ? Colors.white70 : null,
surface: surface,
surfaceContainer: Color.alphaBlend(
@@ -93,6 +98,7 @@ class MornyeArtistHeader extends StatelessWidget {
required this.actions,
required this.showTitle,
this.listeners,
this.logoUrl,
});
final String name;
@@ -100,6 +106,7 @@ class MornyeArtistHeader extends StatelessWidget {
final List<Widget> actions;
final bool showTitle;
final String? listeners;
final String? logoUrl;
@override
Widget build(BuildContext context) =>
@@ -137,25 +144,9 @@ class MornyeArtistHeader extends StatelessWidget {
),
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
artwork,
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0, 0.52, 1],
colors: [
Colors.black.withValues(alpha: 0.15),
surface.withValues(alpha: 0),
surface,
],
),
),
),
],
background: _ArtistCollapsingArtwork(
surface: surface,
child: artwork,
),
),
),
@@ -165,15 +156,7 @@ class MornyeArtistHeader extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
child: Column(
children: [
Text(
name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
_buildIdentity(),
if (listeners != null) ...[
const SizedBox(height: 7),
Text(
@@ -199,4 +182,106 @@ class MornyeArtistHeader extends StatelessWidget {
),
];
}
Widget _buildIdentity() {
final fallback = Text(
name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
final logo = logoUrl?.trim();
if (logo == null || logo.isEmpty) return fallback;
return Semantics(
label: name,
image: true,
excludeSemantics: true,
child: CachedNetworkImage(
imageUrl: logo,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
imageBuilder: (_, provider) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 124),
child: Image(image: provider, fit: BoxFit.contain),
),
placeholder: (_, _) => fallback,
errorWidget: (_, _, _) => fallback,
),
);
}
}
/// Follow the sliver's actual collapse extent so scrolling back restores the
/// same artwork immediately, without a timer or rebuilding the discography.
class _ArtistCollapsingArtwork extends ConsumerWidget {
const _ArtistCollapsingArtwork({required this.surface, required this.child});
final Color surface;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final range = settings == null
? 0.0
: settings.maxExtent - settings.minExtent;
final collapse = range <= 0
? 0.0
: ((settings!.maxExtent - settings.currentExtent) / range).clamp(
0.0,
1.0,
);
final fade = const Interval(
0.30,
0.88,
curve: Curves.easeInOut,
).transform(collapse);
final blur = 18 * Curves.easeOut.transform(collapse);
final blurEnabled =
!MediaQuery.disableAnimationsOf(context) &&
(!ref.watch(lowEndDeviceProvider) ||
ref.watch(backdropBlurEnabledProvider));
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
TickerMode(
enabled: fade < 1,
child: ImageFiltered(
enabled: blurEnabled && blur > 0 && fade < 1,
imageFilter: ImageFilter.blur(
sigmaX: blur,
sigmaY: blur,
tileMode: TileMode.clamp,
),
child: RepaintBoundary(child: child),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0, 0.52, 1],
colors: [
Colors.black.withValues(alpha: 0.15),
surface.withValues(alpha: 0),
surface,
],
),
),
),
ColoredBox(
key: const ValueKey('artist-artwork-fade'),
color: surface.withValues(alpha: fade),
),
],
),
);
}
}
@@ -79,6 +79,8 @@ impl Backend {
"image_url",
"header_image",
"header_video",
"header_logo",
"albums_next",
"provider_id",
],
);
@@ -422,7 +424,15 @@ fn response(kind: &str, value: &Value, check: &Check<'_>) -> Result<Value, Resol
.unwrap_or_default();
let mut info = strings(
value,
&["id", "name", "header_image", "header_video", "provider_id"],
&[
"id",
"name",
"header_image",
"header_video",
"header_logo",
"albums_next",
"provider_id",
],
);
info.insert("images".into(), json!(image));
info.insert("cover_url".into(), json!(cover));
@@ -17,7 +17,11 @@ function collection(id) {
provider_id: "supplied-track", duration_ms: 123456, track_number: i + 1,
explicit: i % 2 === 0, external_links: {example: "https://example.invalid/track/" + i}}))};
}
registerExtension({getAlbum: collection, getPlaylist: collection});
function getArtist(id) {
return {id, name: "Example Artist", image_url: "https://example.invalid/portrait.jpg",
headerLogo: "https://example.invalid/logo.png", albumsNext: "artist-page-2", albums: []};
}
registerExtension({getAlbum: collection, getPlaylist: collection, getArtist});
"#;
fn fixture() -> (tempfile::TempDir, Backend) {
@@ -45,6 +49,26 @@ fn fixture() -> (tempfile::TempDir, Backend) {
(root, backend)
}
#[test]
fn artist_metadata_preserves_logo_separately_from_portrait() {
let (_root, backend) = fixture();
let raw = backend
.get_provider_metadata_json(ID, "artist", "artist-1", &|| Ok(()))
.unwrap();
let value: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
value["artist_info"]["header_logo"],
"https://example.invalid/logo.png"
);
assert_eq!(
value["artist_info"]["images"],
"https://example.invalid/portrait.jpg"
);
assert_eq!(value["artist_info"]["name"], "Example Artist");
assert_eq!(value["artist_info"]["albums_next"], "artist-page-2");
backend.shutdown();
}
#[test]
fn metadata_value_bridge_preserves_album_playlist_and_public_json() {
let (_root, backend) = fixture();
@@ -194,6 +194,8 @@
const tracks = array(first(object(value), ["top_tracks","topTracks","tracks"]), track);
const result = fields(value, [["id","s"],["name","s"],["image_url","s","imageUrl"],
["header_image","s","headerImage"],["header_video","s","headerVideo"],
["header_logo","s","headerLogo"],
["albums_next","s","albumsNext"],
["listeners","i"],["provider_id","s","providerId"]], ["id","name","provider_id"]);
if (albums.length) result.albums = albums;
if (releases.length) result.releases = releases;
+108
View File
@@ -7,6 +7,114 @@ import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
void main() {
testWidgets('unavailable artist logo keeps the name and actions readable', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
home: Scaffold(
body: CustomScrollView(
slivers: const [
MornyeArtistHeader(
name: 'Example Artist',
logoUrl: 'https://example.invalid/unavailable-logo.png',
artwork: ColoredBox(color: Colors.orange),
actions: [Text('Artist action')],
showTitle: false,
),
],
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find
.descendant(
of: find.byType(SliverToBoxAdapter),
matching: find.text('Example Artist'),
)
.hitTestable(),
findsOneWidget,
);
expect(find.text('Artist action').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('artist artwork blurs, disappears, and returns with scroll', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.view.padding = FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.reset);
final controller = ScrollController();
addTearDown(controller.dispose);
const artworkKey = ValueKey('artist-artwork');
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
home: Scaffold(
body: CustomScrollView(
controller: controller,
slivers: const [
MornyeArtistHeader(
name: 'Artist',
artwork: ColoredBox(key: artworkKey, color: Colors.orange),
actions: [Text('Artist action')],
showTitle: false,
),
SliverToBoxAdapter(child: SizedBox(height: 2000)),
],
),
),
),
),
);
await tester.pumpAndSettle();
double fade() => tester
.widget<ColoredBox>(find.byKey(const ValueKey('artist-artwork-fade')))
.color
.a;
ImageFiltered filter() => tester.widget<ImageFiltered>(
find.ancestor(
of: find.byKey(artworkKey),
matching: find.byType(ImageFiltered),
),
);
final artwork = tester.element(find.byKey(artworkKey));
expect(fade(), 0);
expect(filter().enabled, isFalse);
controller.jumpTo(120);
await tester.pumpAndSettle();
expect(filter().enabled, isTrue);
expect(fade(), inExclusiveRange(0, 1));
expect(find.text('Artist action').hitTestable(), findsOneWidget);
controller.jumpTo(210);
await tester.pumpAndSettle();
expect(fade(), 1);
expect(filter().enabled, isFalse);
expect(TickerMode.valuesOf(artwork).enabled, isFalse);
expect(find.text('Artist action').hitTestable(), findsOneWidget);
controller.jumpTo(0);
await tester.pumpAndSettle();
expect(fade(), 0);
expect(filter().enabled, isFalse);
expect(TickerMode.valuesOf(artwork).enabled, isTrue);
expect(tester.element(find.byKey(artworkKey)), same(artwork));
expect(tester.takeException(), isNull);
});
for (final brightness in Brightness.values) {
for (final scale in [1.0, 2.0]) {
testWidgets(