mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(artist): split section builders and dialogs into a part file
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user