Files
SpotiFLAC-Mobile/lib/screens/artist_screen.dart
T

1213 lines
39 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/theme/mornye_icons.dart';
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/screens/track_history_snapshot.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/artist_concert.dart';
import 'package:spotiflac_android/widgets/artist_concerts_button.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
import 'package:spotiflac_android/utils/ttl_cache.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
import 'package:spotiflac_android/utils/local_playback.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/in_library_badge.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
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';
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
part 'artist_screen_widgets.dart';
class _ArtistCache {
static final _cache = TtlCache<_CacheEntry>(
const Duration(minutes: 10),
maxEntries: 24,
);
static _CacheEntry? get(String artistId) => _cache.get(artistId);
static void set(
String artistId, {
required List<ArtistAlbum> albums,
List<ArtistAlbum>? releases,
List<Track>? topTracks,
List<ArtistConcert> concerts = const [],
String? headerImageUrl,
String? headerVideoUrl,
String? headerLogoUrl,
String? albumsNext,
int? monthlyListeners,
}) {
_cache.set(
artistId,
_CacheEntry(
albums: albums,
releases: releases,
topTracks: topTracks,
concerts: concerts,
headerImageUrl: headerImageUrl,
headerVideoUrl: headerVideoUrl,
headerLogoUrl: headerLogoUrl,
albumsNext: albumsNext,
monthlyListeners: monthlyListeners,
),
);
}
}
class _CacheEntry {
final List<ArtistAlbum> albums;
final List<ArtistAlbum>? releases;
final List<Track>? topTracks;
final List<ArtistConcert> concerts;
final String? headerImageUrl;
final String? headerVideoUrl;
final String? headerLogoUrl;
final String? albumsNext;
final int? monthlyListeners;
_CacheEntry({
required this.albums,
this.releases,
this.topTracks,
this.concerts = const [],
this.headerImageUrl,
this.headerVideoUrl,
this.headerLogoUrl,
this.albumsNext,
this.monthlyListeners,
});
}
class ArtistScreen extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
final String? coverUrl;
final String? headerImageUrl;
final String? headerVideoUrl;
final String? headerLogoUrl;
final String? albumsNext;
final int? monthlyListeners;
final List<ArtistAlbum>? albums;
final List<Track>? topTracks;
final List<ArtistConcert> concerts;
final String? extensionId;
const ArtistScreen({
super.key,
required this.artistId,
required this.artistName,
this.coverUrl,
this.headerImageUrl,
this.headerVideoUrl,
this.headerLogoUrl,
this.albumsNext,
this.monthlyListeners,
this.albums,
this.topTracks,
this.concerts = const [],
this.extensionId,
});
@override
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
}
class _ArtistScreenState extends ConsumerState<ArtistScreen>
with SelectionModeMixin<ArtistScreen> {
bool _isLoadingDiscography = false;
final _historySnapshot = TrackHistorySnapshot();
List<ArtistAlbum>? _albums;
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
List<ArtistConcert> _concerts = const [];
String? _headerImageUrl;
String? _headerVideoUrl;
String? _headerLogoUrl;
String? _albumsNext;
bool _isLoadingMoreAlbums = false;
String? _albumsPageError;
DateTime? _albumPageRetryAfter;
int? _monthlyListeners;
String? _error;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
final PageController _popularPageController = PageController();
final SelectionOverlayController _selectionOverlay =
SelectionOverlayController();
int _popularCurrentPage = 0;
bool _isFetchingDiscography = false;
List<ArtistAlbum>? _albumBucketSource;
List<ArtistAlbum> _albumsOnlyBucket = const [];
List<ArtistAlbum> _singlesBucket = const [];
List<ArtistAlbum> _compilationsBucket = const [];
double _responsiveScale({
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale() {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
double _artistAlbumTileSize() {
final scale = _responsiveScale(min: 0.82, max: 1.05);
final textScale = _effectiveTextScale();
return 140 * scale * (1 + (textScale - 1) * 0.12);
}
double _artistAlbumSectionHeight() {
final tileSize = _artistAlbumTileSize();
final textScale = _effectiveTextScale();
return tileSize + 64 + ((textScale - 1) * 14);
}
String? _recommendedDownloadService() {
return _directMetadataProviderId();
}
String _effectiveMetadataProviderIdFromArtistId() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId!;
}
return resolveEffectiveMetadataProvider(
legacyProviderIdFromResourceId(widget.artistId) ?? 'spotify',
ref.read(extensionProvider),
);
}
String? _directMetadataProviderId() {
final providerId = _effectiveMetadataProviderIdFromArtistId();
return providerId.isEmpty ? null : providerId;
}
String _metadataResourceId(String providerId) {
return stripPrefixedResourceId(widget.artistId);
}
@override
void initState() {
super.initState();
_concerts = widget.concerts;
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = _effectiveMetadataProviderIdFromArtistId();
ref
.read(recentAccessProvider.notifier)
.recordArtistAccess(
id: widget.artistId,
name: widget.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
if (widget.extensionId != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_headerLogoUrl = widget.headerLogoUrl;
_albumsNext = widget.albumsNext;
_monthlyListeners = widget.monthlyListeners;
if ((_albums == null || _albums!.isEmpty) ||
(_topTracks == null || _topTracks!.isEmpty)) {
_fetchDiscography();
}
return;
}
final cached = _ArtistCache.get(widget.artistId);
if (widget.albums != null) {
_albums = widget.albums;
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_headerVideoUrl = widget.headerVideoUrl;
_headerLogoUrl = widget.headerLogoUrl;
_albumsNext = widget.albumsNext;
_monthlyListeners = widget.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else if (cached != null) {
_albums = cached.albums;
_releases = cached.releases;
_topTracks = cached.topTracks;
_concerts = cached.concerts;
_headerImageUrl = cached.headerImageUrl;
_headerVideoUrl = cached.headerVideoUrl;
_headerLogoUrl = cached.headerLogoUrl;
_albumsNext = cached.albumsNext;
_monthlyListeners = cached.monthlyListeners;
if (_topTracks == null || _topTracks!.isEmpty) {
_fetchDiscography();
}
} else {
_fetchDiscography();
}
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
@override
void dispose() {
_selectionOverlay.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_popularPageController.dispose();
super.dispose();
}
Future<void> _fetchDiscography() async {
setState(() {
_isLoadingDiscography = true;
_error = null;
});
try {
List<ArtistAlbum> albums;
List<ArtistAlbum>? releases;
List<Track>? topTracks;
List<ArtistConcert> concerts = const [];
String? headerImage;
String? headerVideo;
String? headerLogo;
String? albumsNext;
int? listeners;
if (_directMetadataProviderId() != null) {
final providerId = _directMetadataProviderId()!;
final artistData = await PlatformBridge.getProviderMetadata(
providerId,
'artist',
_metadataResourceId(providerId),
);
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final releasesList = artistData['releases'] as List<dynamic>? ?? [];
if (releasesList.isNotEmpty) {
releases = releasesList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
}
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
}
final artistInfo = artistData['artist_info'] as Map<String, dynamic>?;
concerts = ArtistConcert.parseList(
artistInfo?['concerts'] ?? artistData['concerts'],
);
albumsNext =
(artistInfo?['albums_next'] ?? artistData['albums_next'])
as String?;
headerImage =
artistInfo?['images'] as String? ??
artistInfo?['header_image'] as String? ??
artistInfo?['cover_url'] as String? ??
artistData['header_image'] as String? ??
artistData['cover_url'] as String? ??
artistData['image_url'] as String?;
headerVideo =
artistInfo?['header_video'] as String? ??
artistData['header_video'] as String?;
headerLogo = normalizeRemoteHttpUrl(
(artistInfo?['header_logo'] ?? artistData['header_logo'])?.toString(),
);
listeners =
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
} else {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
concerts = ArtistConcert.parseList(artistData['concerts']);
albumsNext = artistData['albums_next'] as String?;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
}
headerImage = artistData['header_image'] as String?;
headerVideo = artistData['header_video'] as String?;
headerLogo = normalizeRemoteHttpUrl(
artistData['header_logo']?.toString(),
);
listeners = artistData['listeners'] as int?;
} else {
throw StateError('Failed to load artist metadata from extension');
}
}
final finalHeaderImage =
headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
final finalHeaderVideo =
headerVideo ?? _headerVideoUrl ?? widget.headerVideoUrl;
final finalHeaderLogo =
headerLogo ?? _headerLogoUrl ?? widget.headerLogoUrl;
final finalListeners =
listeners ?? _monthlyListeners ?? widget.monthlyListeners;
_ArtistCache.set(
widget.artistId,
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
concerts: concerts,
headerVideoUrl: finalHeaderVideo,
headerLogoUrl: finalHeaderLogo,
albumsNext: albumsNext,
monthlyListeners: finalListeners,
);
if (mounted) {
setState(() {
_albums = albums;
_releases = releases;
_topTracks = topTracks;
_concerts = concerts;
_headerImageUrl = finalHeaderImage;
_headerVideoUrl = finalHeaderVideo;
_headerLogoUrl = finalHeaderLogo;
_albumsNext = albumsNext;
_albumsPageError = null;
_monthlyListeners = finalListeners;
_error = null;
_isLoadingDiscography = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = context.friendlyError(e);
_isLoadingDiscography = false;
});
}
}
}
bool _onAlbumsScroll(ScrollNotification notification) {
final advancing = switch (notification) {
ScrollUpdateNotification() => (notification.scrollDelta ?? 0) > 0,
OverscrollNotification() => notification.overscroll > 0,
_ => false,
};
if (advancing &&
notification.metrics.extentAfter < 480 &&
!_isLoadingDiscography &&
!isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadMoreAlbums();
});
}
return false;
}
Future<void> _loadMoreAlbums() async {
final next = _albumsNext;
final provider = _directMetadataProviderId();
if (_isLoadingMoreAlbums ||
next == null ||
next.isEmpty ||
provider == null ||
(_albumPageRetryAfter?.isAfter(DateTime.now()) ?? false)) {
return;
}
setState(() {
_isLoadingMoreAlbums = true;
_albumsPageError = null;
});
try {
final result = await PlatformBridge.getProviderMetadata(
provider,
'artist',
next,
);
if (!mounted) return;
final info = result['artist_info'] as Map<String, dynamic>? ?? result;
final page = (result['albums'] as List<dynamic>? ?? []).map(
(value) => _parseArtistAlbum(value as Map<String, dynamic>),
);
final merged = {
for (final album in _albums ?? <ArtistAlbum>[]) album.id: album,
};
for (final album in page) {
merged[album.id] = album;
}
final following = info['albums_next'] as String?;
setState(() {
_albums = merged.values.toList();
_albumsNext = following == next ? null : following;
});
_ArtistCache.set(
widget.artistId,
albums: _albums!,
releases: _releases,
topTracks: _topTracks,
concerts: _concerts,
headerImageUrl: _headerImageUrl,
headerVideoUrl: _headerVideoUrl,
headerLogoUrl: _headerLogoUrl,
monthlyListeners: _monthlyListeners,
albumsNext: _albumsNext,
);
} catch (error) {
if (mounted) {
setState(() {
_albumsPageError = context.friendlyError(error);
_albumPageRetryAfter = DateTime.now().add(const Duration(seconds: 3));
});
}
} finally {
if (mounted) setState(() => _isLoadingMoreAlbums = false);
}
}
Track _parseTrack(Map<String, dynamic> data, {ArtistAlbum? album}) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
return Track(
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
.toString(),
albumArtist: normalizeOptionalString(data['album_artist']?.toString()),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId,
albumId: data['album_id']?.toString() ?? album?.id,
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: data['release_date']?.toString(),
albumType:
normalizeOptionalString(data['album_type']?.toString()) ??
album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
composer: data['composer']?.toString(),
genre: data['genre']?.toString(),
label: data['label']?.toString(),
copyright: data['copyright']?.toString(),
comment: data['comment']?.toString(),
upc: normalizeOptionalString(
(data['upc'] ?? data['barcode'])?.toString(),
),
audioQuality: data['audio_quality']?.toString(),
audioModes: data['audio_modes']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId,
previewUrl: data['preview_url']?.toString(),
explicit: parseExplicitFlag(data['explicit']),
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
final totalTracksValue = data['total_tracks'];
final totalTracks = totalTracksValue is int
? totalTracksValue
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: (data['name'] ?? data['title'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: totalTracks,
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
),
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
.toString(),
providerId: data['provider_id']?.toString() ?? widget.extensionId,
);
}
void _ensureAlbumBuckets(List<ArtistAlbum> albums) {
if (identical(albums, _albumBucketSource)) return;
_albumBucketSource = albums;
_albumsOnlyBucket = albums
.where((a) => a.albumType == 'album')
.toList(growable: false);
_singlesBucket = albums
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList(growable: false);
_compilationsBucket = albums
.where((a) => a.albumType == 'compilation')
.toList(growable: false);
}
@override
Widget build(BuildContext context) {
if (context.isMornye) {
return MornyeArtistSurface(
imageSource:
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl,
logoSource: _headerLogoUrl ?? widget.headerLogoUrl,
child: Builder(builder: _buildPage),
);
}
return _buildPage(context);
}
Widget _buildPage(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final albums = _albums ?? [];
_ensureAlbumBuckets(albums);
final releases = _releases ?? const <ArtistAlbum>[];
final albumsOnly = _albumsOnlyBucket;
final singles = _singlesBucket;
final compilations = _compilationsBucket;
final hasDiscography =
!_isLoadingDiscography && _error == null && albums.isNotEmpty;
if (isSelectionMode || _selectionOverlay.isVisible) {
final bottomPadding = MediaQuery.paddingOf(context).bottom;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_syncSelectionOverlay(albums: albums, bottomPadding: bottomPadding);
});
}
return PopScope(
canPop: !isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && isSelectionMode) {
exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
NotificationListener<ScrollNotification>(
onNotification: (notification) => notification.depth == 0
? _onAlbumsScroll(notification)
: false,
child: CustomScrollView(
controller: _scrollController,
slivers: [
..._buildHeader(
context,
colorScheme,
albums: albums,
hasDiscography: hasDiscography,
),
if (_isLoadingDiscography)
SliverToBoxAdapter(
child: ArtistScreenSkeleton(
showCoverHeader:
!context.isMornye &&
(_headerImageUrl ??
widget.headerImageUrl ??
widget.coverUrl) ==
null,
showPopularSection:
!widget.artistId.startsWith('deezer:') &&
!widget.artistId.startsWith('qobuz:') &&
!widget.artistId.startsWith('tidal:'),
),
),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ErrorCard(
error: _error!,
colorScheme: colorScheme,
onRetry: _fetchDiscography,
),
),
),
if (!_isLoadingDiscography && _error == null) ...[
if (context.isMornye &&
albums.isNotEmpty &&
!isSelectionMode)
SliverToBoxAdapter(
child: _buildMornyeFeaturedAlbum(context, albums.first),
),
if (_topTracks != null && _topTracks!.isNotEmpty)
SliverToBoxAdapter(
child: _buildPopularSection(context, colorScheme),
),
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistReleases,
releases,
colorScheme,
),
),
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistAlbums,
albumsOnly,
colorScheme,
),
),
if (singles.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistSingles,
singles,
colorScheme,
showTypeBadge: true,
),
),
if (compilations.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
context,
context.l10n.artistCompilations,
compilations,
colorScheme,
),
),
],
if (_isLoadingMoreAlbums || _albumsPageError != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
if (_albumsPageError != null)
Text(
_albumsPageError!,
textAlign: TextAlign.center,
),
if (_isLoadingMoreAlbums)
const CircularProgressIndicator.adaptive(),
],
),
),
),
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 120 : 32),
),
const NavBarSliverSpacer(),
],
),
),
],
),
),
);
}
void _syncSelectionOverlay({
required List<ArtistAlbum> albums,
required double bottomPadding,
}) {
if (!isSelectionMode) {
_selectionOverlay.hide();
return;
}
_selectionOverlay.show(
context,
(overlayContext) => _buildSelectionBar(
overlayContext,
Theme.of(overlayContext).colorScheme,
albums,
bottomPadding,
),
);
}
@override
void exitSelectionMode() {
HapticFeedback.lightImpact();
super.exitSelectionMode();
}
@override
void toggleSelection(String itemId) {
HapticFeedback.selectionClick();
super.toggleSelection(itemId);
}
void _deselectAll() {
setState(() {
selectedIds.clear();
});
}
Widget _buildSelectionBar(
BuildContext context,
ColorScheme colorScheme,
List<ArtistAlbum> allAlbums,
double bottomPadding,
) {
final allSelected =
selectedIds.length == allAlbums.length && allAlbums.isNotEmpty;
final selectedCount = selectedIds.length;
final selectedAlbums = allAlbums
.where((a) => selectedIds.contains(a.id))
.toList();
final totalTracks = selectedAlbums.fold<int>(
0,
(sum, a) => sum + a.totalTracks,
);
return SelectionBottomBar(
selectedCount: selectedCount,
allSelected: allSelected,
onClose: exitSelectionMode,
onToggleSelectAll: allSelected
? _deselectAll
: () => selectAll(allAlbums.map((a) => a.id)),
bottomPadding: bottomPadding,
allSelectedLabel: context.l10n.tracksCount(totalTracks),
tapToSelectLabel: selectedCount > 0
? context.l10n.tracksCount(totalTracks)
: context.l10n.discographySelectAlbumsSubtitle,
children: [
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: selectedCount > 0
? () => _downloadSelectedAlbums(context, selectedAlbums)
: null,
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.discographyDownloadSelected),
),
),
],
),
],
);
}
void _showDiscographyOptions(
BuildContext context,
ColorScheme colorScheme,
List<ArtistAlbum> albums,
) {
final albumsOnly = albums.where((a) => a.albumType == 'album').toList();
final singles = albums
.where((a) => a.albumType == 'single' || a.albumType == 'ep')
.toList();
final totalTracks = albums.fold<int>(0, (sum, a) => sum + a.totalTracks);
final albumTracks = albumsOnly.fold<int>(
0,
(sum, a) => sum + a.totalTracks,
);
final singleTracks = singles.fold<int>(0, (sum, a) => sum + a.totalTracks);
final pageContext = context;
Widget buildSheet(BuildContext context) => AppBottomSheet(
maxHeightFactor: 0.85,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
children: [
if (!context.isMornye) ...[
Icon(Icons.download, color: colorScheme.primary),
const SizedBox(width: 12),
],
Expanded(
child: Text(
context.l10n.discographyDownload,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: context.isMornye
? FontWeight.w600
: FontWeight.bold,
),
),
),
],
),
),
const Divider(height: 1),
if (albums.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.library_music,
title: context.l10n.discographyDownloadAll,
subtitle: context.l10n.discographyDownloadAllSubtitle(
totalTracks,
albums.length,
),
onTap: () {
Navigator.pop(context);
_downloadAlbums(pageContext, albums);
},
),
if (albumsOnly.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.album,
title: context.l10n.discographyAlbumsOnly,
subtitle: context.l10n.discographyAlbumsOnlySubtitle(
albumTracks,
albumsOnly.length,
),
onTap: () {
Navigator.pop(context);
_downloadAlbums(pageContext, albumsOnly);
},
),
if (singles.isNotEmpty)
_DiscographyOptionTile(
icon: Icons.music_note,
title: context.l10n.discographySinglesOnly,
subtitle: context.l10n.discographySinglesOnlySubtitle(
singleTracks,
singles.length,
),
onTap: () {
Navigator.pop(context);
_downloadAlbums(pageContext, singles);
},
),
_DiscographyOptionTile(
icon: Icons.checklist,
title: context.l10n.discographySelectAlbums,
subtitle: context.l10n.discographySelectAlbumsSubtitle,
showDivider: false,
onTap: () {
Navigator.pop(context);
enterSelectionMode(albums.first.id);
},
),
const SizedBox(height: 8),
],
),
),
);
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: context.isMornye
? Colors.transparent
: colorScheme.surfaceContainerHigh,
builder: (sheetContext) => sheetContext.isMornye
? Theme(
data: MornyeTheme.fromContext(
sheetContext,
brightness: Brightness.dark,
),
child: MornyeGlassPanel(
tintOpacity: 0.78,
child: Builder(builder: buildSheet),
),
)
: buildSheet(sheetContext),
);
}
Future<void> _downloadAlbums(
BuildContext context,
List<ArtistAlbum> albums,
) async {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload || settings.allowQualityVariants) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
_fetchAndQueueAlbums(albums, service, quality);
},
);
} else {
_fetchAndQueueAlbums(albums, settings.defaultService, null);
}
}
Future<void> _downloadSelectedAlbums(
BuildContext context,
List<ArtistAlbum> albums,
) async {
exitSelectionMode();
await _downloadAlbums(context, albums);
}
Future<void> _toggleFavoriteArtist(BuildContext context) async {
final providerId = _directMetadataProviderId();
final imageUrl =
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
final added = await ref
.read(libraryCollectionsProvider.notifier)
.toggleFavoriteArtist(
artistId: _metadataResourceId(providerId ?? ''),
providerId: providerId,
name: widget.artistName,
imageUrl: imageUrl,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
added
? context.l10n.collectionAddedToFavoriteArtists(widget.artistName)
: context.l10n.collectionRemovedFromFavoriteArtists(
widget.artistName,
),
),
),
);
}
Future<void> _fetchAndQueueAlbums(
List<ArtistAlbum> albums,
String service,
String? qualityOverride,
) async {
if (_isFetchingDiscography) return;
setState(() => _isFetchingDiscography = true);
if (!mounted) {
setState(() => _isFetchingDiscography = false);
return;
}
final progressDialogKey = GlobalKey<_FetchingProgressDialogState>();
showAppDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _FetchingProgressDialog(
key: progressDialogKey,
totalAlbums: albums.length,
onCancel: () {
setState(() => _isFetchingDiscography = false);
Navigator.pop(ctx);
},
),
);
final allTracks = <Track>[];
int fetchedCount = 0;
int failedCount = 0;
for (final album in albums) {
if (!_isFetchingDiscography) break;
try {
final tracks = await _fetchAlbumTracks(album);
allTracks.addAll(tracks);
} catch (e) {
failedCount++;
}
fetchedCount++;
if (mounted) {
progressDialogKey.currentState?.updateProgress(
fetchedCount,
albums.length,
);
}
}
setState(() => _isFetchingDiscography = false);
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
if (failedCount > 0 && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.discographyFailedToFetch)),
);
}
if (allTracks.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.discographyNoAlbums)),
);
}
return;
}
final settings = ref.read(settingsProvider);
final skipExisting =
settings.deduplicateDownloads && !settings.allowQualityVariants;
final historyLookups = allTracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = skipExisting
? await ref.read(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
).future,
)
: const <String>{};
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (var i = 0; i < allTracks.length; i++) {
final track = allTracks[i];
final isDownloaded =
skipExisting &&
existingHistoryKeys.contains(historyLookups[i].lookupKey);
if (!isDownloaded) {
tracksToQueue.add(track);
} else {
skippedCount++;
}
}
if (tracksToQueue.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.discographySkippedDownloaded(0, skippedCount),
),
),
);
}
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: qualityOverride,
);
if (mounted) {
final message = skippedCount > 0
? context.l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedCount,
)
: context.l10n.discographyAddedToQueue(tracksToQueue.length);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: buildViewQueueSnackBarAction(context),
),
);
}
}
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
final providerId = album.providerId;
if (providerId != null && providerId.isNotEmpty) {
final resourceId = stripPrefixedResourceId(album.id);
final metadata = await PlatformBridge.getProviderMetadata(
providerId,
'album',
resourceId,
);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else {
final url = 'https://open.spotify.com/album/${album.id}';
final result = await PlatformBridge.handleURLWithExtension(url);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
}
return [];
}
}