refactor(artist): split section builders and dialogs into a part file

This commit is contained in:
zarzet
2026-07-26 23:10:18 +07:00
parent ea172ba17b
commit 5412037ac8
2 changed files with 965 additions and 959 deletions
+2 -959
View File
@@ -32,6 +32,8 @@ import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
part 'artist_screen_widgets.dart';
class _ArtistCache {
static final _cache = TtlCache<_CacheEntry>(const Duration(minutes: 10));
@@ -1089,963 +1091,4 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
}
return [];
}
Widget _buildHeader(
BuildContext context,
ColorScheme colorScheme, {
required List<ArtistAlbum> 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;
final isDark = Theme.of(context).brightness == Brightness.dark;
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 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
? 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)
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: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7),
isDark
? colorScheme.surface
: Colors.black.withValues(alpha: 0.85),
],
stops: const [0.0, 0.5, 0.75, 1.0],
),
),
),
Positioned(
left: 16,
right: 16,
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: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(
alpha: 0.5,
),
),
],
),
),
],
],
),
),
if (!isSelectionMode) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () => _toggleFavoriteArtist(context),
icon: Icon(
isFavoriteArtist
? Icons.favorite
: Icons.favorite_border,
size: 26,
),
color: isFavoriteArtist
? colorScheme.error
: Colors.black87,
tooltip: isFavoriteArtist
? context.l10n.artistOptionRemoveFromFavorites
: context.l10n.artistOptionAddToFavorites,
),
),
],
if (hasDiscography && !isSelectionMode) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () => _showDiscographyOptions(
context,
colorScheme,
albums,
),
icon: const Icon(Icons.download_rounded, size: 26),
color: Colors.black87,
tooltip: context.l10n.discographyDownload,
),
),
],
],
),
),
],
),
stretchModes: const [StretchMode.zoomBackground],
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
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!;
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
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),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 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
? 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),
],
),
),
);
},
);
}
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) 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<ArtistAlbum> 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: [
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<void>(
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<void>(
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),
),
],
);
}
}
+963
View File
@@ -0,0 +1,963 @@
// 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<ArtistAlbum> 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;
final isDark = Theme.of(context).brightness == Brightness.dark;
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 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
? 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)
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: [
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7),
isDark
? colorScheme.surface
: Colors.black.withValues(alpha: 0.85),
],
stops: const [0.0, 0.5, 0.75, 1.0],
),
),
),
Positioned(
left: 16,
right: 16,
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: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 4,
color: Colors.black.withValues(alpha: 0.5),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (listenersText != null) ...[
const SizedBox(height: 4),
Text(
listenersText,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
blurRadius: 2,
color: Colors.black.withValues(
alpha: 0.5,
),
),
],
),
),
],
],
),
),
if (!isSelectionMode) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () => _toggleFavoriteArtist(context),
icon: Icon(
isFavoriteArtist
? Icons.favorite
: Icons.favorite_border,
size: 26,
),
color: isFavoriteArtist
? colorScheme.error
: Colors.black87,
tooltip: isFavoriteArtist
? context.l10n.artistOptionRemoveFromFavorites
: context.l10n.artistOptionAddToFavorites,
),
),
],
if (hasDiscography && !isSelectionMode) ...[
const SizedBox(width: 12),
Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: () => _showDiscographyOptions(
context,
colorScheme,
albums,
),
icon: const Icon(Icons.download_rounded, size: 26),
color: Colors.black87,
tooltip: context.l10n.discographyDownload,
),
),
],
],
),
),
],
),
stretchModes: const [StretchMode.zoomBackground],
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.arrow_back, color: Colors.white),
),
onPressed: () => Navigator.pop(context),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
tooltip: context.l10n.openInOtherServices,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
shape: BoxShape.circle,
),
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
),
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!;
final historyLookups = tracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
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),
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
context,
ref,
track,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 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
? 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),
],
),
),
);
},
);
}
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
if (isQueued) 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<ArtistAlbum> 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: [
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<void>(
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<void>(
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),
),
],
);
}
}