mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(artists): add branded headers and incremental release loading
This commit is contained in:
@@ -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
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user