// ignore_for_file: invalid_use_of_protected_member part of 'artist_screen.dart'; extension _ArtistScreenSections on _ArtistScreenState { Widget _buildHeader( BuildContext context, ColorScheme colorScheme, { required List albums, required bool hasDiscography, }) { String? imageUrl = _headerImageUrl; if (imageUrl == null || imageUrl.isEmpty) { imageUrl = widget.headerImageUrl; } if (imageUrl == null || imageUrl.isEmpty) { imageUrl = widget.coverUrl; } final hasValidImage = imageUrl != null && imageUrl.isNotEmpty && Uri.tryParse(imageUrl)?.hasAuthority == true; String? headerVideoUrl = _headerVideoUrl; if (headerVideoUrl == null || headerVideoUrl.isEmpty) { headerVideoUrl = widget.headerVideoUrl; } final hasMotionBanner = headerVideoUrl != null && headerVideoUrl.isNotEmpty && Uri.tryParse(headerVideoUrl)?.hasAuthority == true; String? listenersText; final listeners = _monthlyListeners ?? widget.monthlyListeners; if (listeners != null && listeners > 0) { final formatter = NumberFormat.compact(); listenersText = context.l10n.artistMonthlyListeners( formatter.format(listeners), ); } final favoriteProviderId = _directMetadataProviderId(); final favoriteArtistId = _metadataResourceId(favoriteProviderId ?? ''); final isFavoriteArtist = ref.watch( libraryCollectionsProvider.select( (state) => state.isFavoriteArtist( artistId: favoriteArtistId, providerId: favoriteProviderId, ), ), ); return CoverPaletteBuilder( imageSource: hasValidImage ? imageUrl : null, builder: (context, headerScheme) => _buildArtistAppBar( context, colorScheme, headerScheme, albums: albums, hasDiscography: hasDiscography, imageUrl: imageUrl, hasValidImage: hasValidImage, headerVideoUrl: headerVideoUrl, hasMotionBanner: hasMotionBanner, listenersText: listenersText, isFavoriteArtist: isFavoriteArtist, ), ); } Widget _buildArtistAppBar( BuildContext context, ColorScheme colorScheme, ColorScheme headerScheme, { required List albums, required bool hasDiscography, required String? imageUrl, required bool hasValidImage, required String? headerVideoUrl, required bool hasMotionBanner, required String? listenersText, required bool isFavoriteArtist, }) { final edgeInset = detailHeaderEdgeInset(context); return SliverAppBar( expandedHeight: hasDiscography ? 420 : 380, pinned: true, stretch: true, backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: _showTitleInAppBar ? 1.0 : 0.0, child: Text( widget.artistName, style: TextStyle( color: colorScheme.onSurface, fontWeight: FontWeight.w600, fontSize: 16, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), flexibleSpace: FlexibleSpaceBar( collapseMode: CollapseMode.none, background: Stack( fit: StackFit.expand, children: [ if (hasMotionBanner) MotionHeaderBanner( videoUrl: headerVideoUrl!, fallback: hasValidImage ? DownloadableCover( coverUrl: imageUrl, baseName: widget.artistName, child: CachedCoverImage( imageUrl: imageUrl!, fit: BoxFit.cover, alignment: Alignment.topCenter, memCacheWidth: 800, placeholder: (context, url) => Container( color: colorScheme.surfaceContainerHighest, ), errorWidget: (context, url, error) => Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.person, size: 80, color: colorScheme.onSurfaceVariant, ), ), ), ) : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.person, size: 80, color: colorScheme.onSurfaceVariant, ), ), ) else if (hasValidImage) DownloadableCover( coverUrl: imageUrl, baseName: widget.artistName, child: CachedCoverImage( imageUrl: imageUrl!, fit: BoxFit.cover, alignment: Alignment.topCenter, memCacheWidth: 800, placeholder: (context, url) => Container(color: colorScheme.surfaceContainerHighest), errorWidget: (context, url, error) => Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.person, size: 80, color: colorScheme.onSurfaceVariant, ), ), ), ) else Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.person, size: 80, color: colorScheme.onSurfaceVariant, ), ), Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ headerScheme.surface.withValues(alpha: 0), headerScheme.surface.withValues(alpha: 0.35), headerScheme.surface.withValues(alpha: 0.75), headerScheme.surface, ], stops: const [0.0, 0.5, 0.75, 1.0], ), ), ), Positioned( left: 16 + edgeInset, right: 16 + edgeInset, bottom: 16, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( widget.artistName, style: Theme.of(context).textTheme.headlineLarge ?.copyWith( fontWeight: FontWeight.bold, color: headerScheme.onSurface, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), if (listenersText != null) ...[ const SizedBox(height: 4), Text( listenersText, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: headerScheme.onSurfaceVariant, ), ), ], ], ), ), if (!isSelectionMode) ...[ const SizedBox(width: 12), Container( width: 52, height: 52, decoration: BoxDecoration( color: headerScheme.primaryContainer, shape: BoxShape.circle, ), child: IconButton( onPressed: () => _toggleFavoriteArtist(context), icon: Icon( isFavoriteArtist ? Icons.favorite : Icons.favorite_border, size: 26, ), color: isFavoriteArtist ? headerScheme.error : headerScheme.onPrimaryContainer, tooltip: isFavoriteArtist ? context.l10n.artistOptionRemoveFromFavorites : context.l10n.artistOptionAddToFavorites, ), ), ], if (hasDiscography && !isSelectionMode) ...[ const SizedBox(width: 12), Container( width: 52, height: 52, decoration: BoxDecoration( color: headerScheme.primaryContainer, shape: BoxShape.circle, ), child: IconButton( onPressed: () => _showDiscographyOptions( context, colorScheme, albums, ), icon: const Icon(Icons.download_rounded, size: 26), color: headerScheme.onPrimaryContainer, tooltip: context.l10n.discographyDownload, ), ), ], ], ), ), ], ), stretchModes: const [StretchMode.zoomBackground], ), leadingWidth: kToolbarHeight + edgeInset, leading: Padding( padding: EdgeInsets.only(left: edgeInset), child: HeaderCircleButton( icon: Icons.arrow_back, tooltip: MaterialLocalizations.of(context).backButtonTooltip, onPressed: () => Navigator.pop(context), ), ), actionsPadding: EdgeInsets.only(right: edgeInset), actions: [ HeaderCircleButton( icon: Icons.open_in_new_rounded, tooltip: context.l10n.openInOtherServices, onPressed: () => _showShareSheet(context), ), ], ); } void _showShareSheet(BuildContext context) { CrossExtensionShareSheet.show( context, name: widget.artistName, artists: '', type: 'artist', sourceExtensionId: _directMetadataProviderId() ?? '', ); } Widget _buildPopularSection(ColorScheme colorScheme) { if (_topTracks == null || _topTracks!.isEmpty) { return const SizedBox.shrink(); } final tracks = _topTracks!; _historySnapshot.update(tracks); final historyLookups = _historySnapshot.lookups; final existingHistoryKeys = ref.watch( downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request), ); const tracksPerPage = 5; final pageCount = (tracks.length / tracksPerPage).ceil(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), child: Text( context.l10n.artistPopular, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), SizedBox( height: tracksPerPage * 64.0, child: PageView.builder( controller: _popularPageController, itemCount: pageCount, onPageChanged: (page) { setState(() { _popularCurrentPage = page; }); }, itemBuilder: (context, pageIndex) { final startIndex = pageIndex * tracksPerPage; final endIndex = (startIndex + tracksPerPage).clamp( 0, tracks.length, ); final pageTracks = tracks.sublist(startIndex, endIndex); return Column( children: pageTracks.asMap().entries.map((entry) { final globalIndex = startIndex + entry.key; return _buildPopularTrackItem( globalIndex + 1, entry.value, colorScheme, existingHistoryKeys.contains( historyLookups[globalIndex].lookupKey, ), ); }).toList(), ); }, ), ), if (pageCount > 1) Center( child: Padding( padding: const EdgeInsets.only(top: 8), child: Row( mainAxisSize: MainAxisSize.min, children: List.generate(pageCount, (index) { final isActive = _popularCurrentPage == index; return Container( margin: const EdgeInsets.symmetric(horizontal: 3), width: isActive ? 8 : 6, height: isActive ? 8 : 6, decoration: BoxDecoration( shape: BoxShape.circle, color: isActive ? colorScheme.primary : colorScheme.onSurfaceVariant.withValues(alpha: 0.3), ), ); }), ), ), ), ], ); } Widget _buildPopularTrackItem( int rank, Track track, ColorScheme colorScheme, bool isInHistory, ) { return Consumer( builder: (context, ref, child) { final queueItem = ref.watch( downloadQueueLookupProvider.select( (lookup) => lookup.byTrackId[track.id], ), ); final showLocalLibraryIndicator = ref.watch( settingsProvider.select( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, ), ); final isInLocalLibrary = showLocalLibraryIndicator ? ref.watch( localLibraryProvider.select( (state) => state.existsInLibrary( isrc: track.isrc, trackName: track.name, artistName: track.artistName, ), ), ) : false; final isQueued = queueItem != null; return InkWell( onTap: () => _handlePopularTrackTap( track, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, ), onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet( context, ref, track, hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 10, 8), child: Row( children: [ SizedBox( width: 24, child: Text( '$rank', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), textAlign: TextAlign.center, ), ), const SizedBox(width: 12), track.coverUrl != null ? DownloadableCover( coverUrl: track.coverUrl, baseName: '${track.artistName} - ${track.name}', child: CachedCoverImage( imageUrl: track.coverUrl!, width: 48, height: 48, borderRadius: BorderRadius.circular(4), placeholder: (context, url) => ShimmerLoading( child: Container( width: 48, height: 48, color: colorScheme.surfaceContainerHighest, ), ), errorWidget: (context, url, error) => Container( width: 48, height: 48, color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24, ), ), ), ) : Container( width: 48, height: 48, color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( track.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (track.albumName.isNotEmpty || isInLocalLibrary || isInHistory) Row( children: [ if (track.albumName.isNotEmpty) Expanded( child: ClickableAlbumName( albumName: track.albumName, albumId: track.albumId, artistName: track.artistName, coverUrl: track.coverUrl, extensionId: widget.extensionId, style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (isInLocalLibrary || isInHistory) ...[ if (track.albumName.isNotEmpty) const SizedBox(width: 6), const InLibraryBadge(), ], ], ), ], ), ), TrackCollectionQuickActions( track: track, hasLocalPlaybackCandidate: isInHistory || isInLocalLibrary, ), ], ), ), ); }, ); } void _handlePopularTrackTap( Track track, { required bool isQueued, required bool isInHistory, required bool isInLocalLibrary, }) async { if (isQueued) return; final settings = ref.read(settingsProvider); if (settings.allowQualityVariants && (isInHistory || isInLocalLibrary)) { _downloadTrack(track); return; } final playedLocal = await playLocalIfAvailable(context, ref, track); if (playedLocal) { return; } _downloadTrack(track); } void _downloadTrack(Track track) { final settings = ref.read(settingsProvider); ref.read(settingsProvider.notifier).setHasSearchedBefore(); void enqueue(String service, {String? quality}) { ref .read(downloadQueueProvider.notifier) .addToQueue(track, service, qualityOverride: quality); showAddedToQueueSnackBar(context, track.name); } if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { DownloadServicePicker.show( context, recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { if (!mounted) return; enqueue(service, quality: quality); }, ); return; } final extensionState = ref.read(extensionProvider); final service = resolveEffectiveDownloadService( settings.defaultService, extensionState, ); if (service.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), ); return; } enqueue(service); } Widget _buildAlbumSection( String title, List albums, ColorScheme colorScheme, { bool showTypeBadge = false, }) { final sectionHeight = _artistAlbumSectionHeight(); final tileSize = _artistAlbumTileSize(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 12), child: Text( '$title (${albums.length})', style: Theme.of( context, ).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( album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight, showTypeBadge: showTypeBadge, ), ); }, ), ), ], ); } Widget _buildAlbumCard( ArtistAlbum album, ColorScheme colorScheme, { required double tileSize, required double sectionHeight, bool showTypeBadge = false, }) { final isSelected = selectedIds.contains(album.id); return Semantics( button: true, selected: isSelectionMode && isSelected, label: isSelectionMode ? context.l10n.a11ySelectAlbum(album.name) : context.l10n.a11yOpenAlbum(album.name), child: GestureDetector( onTap: () { if (isSelectionMode) { toggleSelection(album.id); } else { _navigateToAlbum(album); } }, onLongPress: () { if (!isSelectionMode) { enterSelectionMode(album.id); } }, child: Container( width: tileSize, height: sectionHeight, margin: const EdgeInsets.symmetric(horizontal: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox.square( dimension: tileSize, child: Stack( fit: StackFit.expand, children: [ DownloadableCover( coverUrl: album.coverUrl, baseName: '${album.artists} - ${album.name}', enabled: !isSelectionMode, child: ClipRRect( borderRadius: BorderRadius.circular(8), child: album.coverUrl != null ? CachedCoverImage( imageUrl: album.coverUrl!, width: tileSize, height: tileSize, fit: BoxFit.cover, memCacheWidth: (tileSize * 2).round(), memCacheHeight: (tileSize * 2).round(), placeholder: (context, url) => ShimmerLoading( child: Container( color: colorScheme.surfaceContainerHighest, ), ), errorWidget: (context, url, error) => Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.album, color: colorScheme.onSurfaceVariant, size: 40, ), ), ) : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.album, color: colorScheme.onSurfaceVariant, size: 40, ), ), ), ), if (isSelectionMode) Positioned.fill( child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: isSelected ? colorScheme.primary.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1), border: isSelected ? Border.all( color: colorScheme.primary, width: 3, ) : null, ), ), ), if (isSelectionMode) Positioned( top: 8, right: 8, child: AnimatedSelectionCheckbox( visible: true, selected: isSelected, colorScheme: colorScheme, size: 28, unselectedColor: colorScheme.surface.withValues( alpha: 0.9, ), ), ), if (showTypeBadge) Positioned( left: 6, bottom: 6, child: Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(4), ), child: Text( album.albumType == 'ep' ? context.l10n.releaseTypeEp : context.l10n.releaseTypeSingle, style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600, ), ), ), ), ], ), ), const SizedBox(height: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( album.name, style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), const SizedBox(height: 2), Text( album.totalTracks > 0 ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' : album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ), ), ); } void _navigateToAlbum(ArtistAlbum album) { ref.read(settingsProvider.notifier).setHasSearchedBefore(); if (album.providerId != null && album.providerId!.isNotEmpty) { Navigator.push( context, MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: album.providerId!, albumId: album.id, albumName: album.name, coverUrl: album.coverUrl, initialAlbumType: album.albumType, initialTotalTracks: album.totalTracks, ), ), ); } else { Navigator.push( context, MaterialPageRoute( builder: (context) => AlbumScreen( albumId: album.id, albumName: album.name, coverUrl: album.coverUrl, ), ), ); } } } class _DiscographyOptionTile extends StatelessWidget { final IconData icon; final String title; final String subtitle; final VoidCallback onTap; const _DiscographyOptionTile({ required this.icon, required this.title, required this.subtitle, required this.onTap, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), leading: Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 24), ), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text( subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12), ), trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), onTap: onTap, ); } } class _FetchingProgressDialog extends StatefulWidget { final int totalAlbums; final VoidCallback onCancel; const _FetchingProgressDialog({ super.key, required this.totalAlbums, required this.onCancel, }); @override State<_FetchingProgressDialog> createState() => _FetchingProgressDialogState(); } class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { int _current = 0; int _total = 0; @override void initState() { super.initState(); _total = widget.totalAlbums; } void updateProgress(int current, int total) { if (mounted) { setState(() { _current = current; _total = total; }); } } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final progress = _total > 0 ? _current / _total : 0.0; return AlertDialog( backgroundColor: colorScheme.surfaceContainerHigh, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), content: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), SizedBox( width: 64, height: 64, child: Stack( alignment: Alignment.center, children: [ CircularProgressIndicator( value: progress > 0 ? progress : null, strokeWidth: 4, backgroundColor: colorScheme.surfaceContainerHighest, ), Icon(Icons.library_music, color: colorScheme.primary, size: 24), ], ), ), const SizedBox(height: 20), Text( context.l10n.discographyFetchingTracks, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( context.l10n.discographyFetchingAlbum(_current, _total), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progress > 0 ? progress : null, backgroundColor: colorScheme.surfaceContainerHighest, minHeight: 6, ), ), ], ), actions: [ TextButton( onPressed: widget.onCancel, child: Text(context.l10n.dialogCancel), ), ], ); } }