diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 241baefb..d8c2699d 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -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? artistAlbums; final List? 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? artistAlbums, List? 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 { 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, diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index a385e252..b8133bee 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -62,6 +62,8 @@ class _ArtistCache { List? 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? 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? albums; final List? 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 List? _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 _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 _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 _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 List? topTracks; String? headerImage; String? headerVideo; + String? headerLogo; + String? albumsNext; int? listeners; if (_directMetadataProviderId() != null) { @@ -319,6 +344,9 @@ class _ArtistScreenState extends ConsumerState } final artistInfo = artistData['artist_info'] as Map?; + 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 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 if (result != null && result['artist'] != null) { final artistData = result['artist'] as Map; + albumsNext = artistData['albums_next'] as String?; final albumsList = artistData['albums'] as List? ?? []; albums = albumsList .map((a) => _parseArtistAlbum(a as Map)) @@ -352,6 +384,9 @@ class _ArtistScreenState extends ConsumerState 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 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 topTracks: topTracks, headerImageUrl: finalHeaderImage, headerVideoUrl: finalHeaderVideo, + headerLogoUrl: finalHeaderLogo, + albumsNext: albumsNext, monthlyListeners: finalListeners, ); @@ -382,6 +421,9 @@ class _ArtistScreenState extends ConsumerState _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 } } + 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 _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? ?? result; + final page = (result['albums'] as List? ?? []).map( + (value) => _parseArtistAlbum(value as Map), + ); + final merged = { + for (final album in _albums ?? []) 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 data, {ArtistAlbum? album}) { int durationMs = 0; final durationValue = data['duration_ms']; @@ -526,93 +644,117 @@ class _ArtistScreenState extends ConsumerState 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( + 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(), - ], + ), ), ], ), diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index fd4cd190..ab022524 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -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( + 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, + ), + ); + }, + ), ), ), ], diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 9633c0d5..e50fe60b 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -653,6 +653,8 @@ class _HomeTabState extends ConsumerState coverUrl: trackState.coverUrl, headerImageUrl: trackState.headerImageUrl, headerVideoUrl: trackState.headerVideoUrl, + headerLogoUrl: trackState.headerLogoUrl, + albumsNext: trackState.artistAlbumsNext, albums: trackState.artistAlbums!, extensionId: extensionId, ), diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 2fa4d8d6..4f0bec71 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -1335,6 +1335,8 @@ class _ExtensionArtistScreenState extends ConsumerState List? _topTracks; String? _headerImageUrl; String? _headerVideoUrl; + String? _headerLogoUrl; + String? _albumsNext; int? _monthlyListeners; bool _isLoading = true; String? _error; @@ -1392,12 +1394,17 @@ class _ExtensionArtistScreenState extends ConsumerState 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 coverUrl: widget.coverUrl, headerImageUrl: _headerImageUrl, headerVideoUrl: _headerVideoUrl, + headerLogoUrl: _headerLogoUrl, + albumsNext: _albumsNext, monthlyListeners: _monthlyListeners, albums: _albums, topTracks: _topTracks, diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index 601aa17a..889b0721 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -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, ), diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart index 32746785..b9c3186a 100644 --- a/lib/widgets/mornye_artist_header.dart +++ b/lib/widgets/mornye_artist_header.dart @@ -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 { 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 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(); + 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), + ), + ], + ), + ); + } } diff --git a/rust_backend/crates/extensions/src/backend/provider_metadata.rs b/rust_backend/crates/extensions/src/backend/provider_metadata.rs index 3d437829..98df916f 100644 --- a/rust_backend/crates/extensions/src/backend/provider_metadata.rs +++ b/rust_backend/crates/extensions/src/backend/provider_metadata.rs @@ -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 (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(); diff --git a/rust_backend/crates/extensions/src/provider.js b/rust_backend/crates/extensions/src/provider.js index d2f58dcd..138f93df 100644 --- a/rust_backend/crates/extensions/src/provider.js +++ b/rust_backend/crates/extensions/src/provider.js @@ -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; diff --git a/test/mornye_detail_headers_test.dart b/test/mornye_detail_headers_test.dart index 96643d21..325e740b 100644 --- a/test/mornye_detail_headers_test.dart +++ b/test/mornye_detail_headers_test.dart @@ -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(find.byKey(const ValueKey('artist-artwork-fade'))) + .color + .a; + ImageFiltered filter() => tester.widget( + 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(