mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
- Add homeFeedProvider field to AppSettings with picker UI in extensions page - Update explore_provider to respect user's home feed provider preference - Add normalizeCoverReference() and normalizeRemoteHttpUrl() to filter invalid cover URLs (no scheme, no host, protocol-relative) - Apply cover URL normalization across all screens and providers to prevent 'no host specified in URI' errors from Qobuz - Propagate CoverURL from QobuzDownloadResult through Go backend so cover art is available even when request metadata is incomplete
2165 lines
73 KiB
Dart
2165 lines
73 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/models/track.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/recent_access_provider.dart';
|
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
|
import 'package:spotiflac_android/screens/home_tab.dart'
|
|
show ExtensionAlbumScreen;
|
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
|
|
|
class _ArtistCache {
|
|
static final Map<String, _CacheEntry> _cache = {};
|
|
static const Duration _ttl = Duration(minutes: 10);
|
|
|
|
static _CacheEntry? get(String artistId) {
|
|
final entry = _cache[artistId];
|
|
if (entry == null) return null;
|
|
if (DateTime.now().isAfter(entry.expiresAt)) {
|
|
_cache.remove(artistId);
|
|
return null;
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
static void set(
|
|
String artistId, {
|
|
required List<ArtistAlbum> albums,
|
|
List<ArtistAlbum>? releases,
|
|
List<Track>? topTracks,
|
|
String? headerImageUrl,
|
|
int? monthlyListeners,
|
|
}) {
|
|
_cache[artistId] = _CacheEntry(
|
|
albums: albums,
|
|
releases: releases,
|
|
topTracks: topTracks,
|
|
headerImageUrl: headerImageUrl,
|
|
monthlyListeners: monthlyListeners,
|
|
expiresAt: DateTime.now().add(_ttl),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CacheEntry {
|
|
final List<ArtistAlbum> albums;
|
|
final List<ArtistAlbum>? releases;
|
|
final List<Track>? topTracks;
|
|
final String? headerImageUrl;
|
|
final int? monthlyListeners;
|
|
final DateTime expiresAt;
|
|
|
|
_CacheEntry({
|
|
required this.albums,
|
|
this.releases,
|
|
this.topTracks,
|
|
this.headerImageUrl,
|
|
this.monthlyListeners,
|
|
required this.expiresAt,
|
|
});
|
|
}
|
|
|
|
class ArtistScreen extends ConsumerStatefulWidget {
|
|
final String artistId;
|
|
final String artistName;
|
|
final String? coverUrl;
|
|
final String? headerImageUrl;
|
|
final int? monthlyListeners;
|
|
final List<ArtistAlbum>? albums;
|
|
final List<Track>? topTracks;
|
|
final String? extensionId;
|
|
|
|
const ArtistScreen({
|
|
super.key,
|
|
required this.artistId,
|
|
required this.artistName,
|
|
this.coverUrl,
|
|
this.headerImageUrl,
|
|
this.monthlyListeners,
|
|
this.albums,
|
|
this.topTracks,
|
|
this.extensionId,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<ArtistScreen> createState() => _ArtistScreenState();
|
|
}
|
|
|
|
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|
bool _isLoadingDiscography = false;
|
|
List<ArtistAlbum>? _albums;
|
|
List<ArtistAlbum>? _releases;
|
|
List<Track>? _topTracks;
|
|
String? _headerImageUrl;
|
|
int? _monthlyListeners;
|
|
String? _error;
|
|
|
|
bool _showTitleInAppBar = false;
|
|
final ScrollController _scrollController = ScrollController();
|
|
final PageController _popularPageController = PageController();
|
|
int _popularCurrentPage = 0;
|
|
|
|
bool _isSelectionMode = false;
|
|
final Set<String> _selectedAlbumIds = {};
|
|
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);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_scrollController.addListener(_onScroll);
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final providerId =
|
|
widget.extensionId ??
|
|
(() {
|
|
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
|
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
|
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
|
return 'spotify';
|
|
})();
|
|
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;
|
|
_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;
|
|
_monthlyListeners = widget.monthlyListeners;
|
|
|
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
|
_fetchDiscography();
|
|
}
|
|
} else if (cached != null) {
|
|
_albums = cached.albums;
|
|
_releases = cached.releases;
|
|
_topTracks = cached.topTracks;
|
|
_headerImageUrl = cached.headerImageUrl;
|
|
_monthlyListeners = cached.monthlyListeners;
|
|
|
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
|
_fetchDiscography();
|
|
}
|
|
} else {
|
|
_fetchDiscography();
|
|
}
|
|
}
|
|
|
|
void _onScroll() {
|
|
// Show title when scrolled past the header (280px trigger)
|
|
final shouldShow = _scrollController.offset > 280;
|
|
if (shouldShow != _showTitleInAppBar) {
|
|
setState(() => _showTitleInAppBar = shouldShow);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
_popularPageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _fetchDiscography() async {
|
|
setState(() => _isLoadingDiscography = true);
|
|
try {
|
|
List<ArtistAlbum> albums;
|
|
List<ArtistAlbum>? releases;
|
|
List<Track>? topTracks;
|
|
String? headerImage;
|
|
int? listeners;
|
|
|
|
if (widget.artistId.startsWith('deezer:')) {
|
|
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
|
'artist',
|
|
deezerArtistId,
|
|
);
|
|
final albumsList = metadata['albums'] as List<dynamic>;
|
|
albums = albumsList
|
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
.toList();
|
|
} else if (widget.artistId.startsWith('qobuz:')) {
|
|
final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', '');
|
|
final metadata = await PlatformBridge.getQobuzMetadata(
|
|
'artist',
|
|
qobuzArtistId,
|
|
);
|
|
final albumsList = metadata['albums'] as List<dynamic>;
|
|
albums = albumsList
|
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
.toList();
|
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
|
|
headerImage = artistInfo?['images'] as String?;
|
|
} else if (widget.artistId.startsWith('tidal:')) {
|
|
final tidalArtistId = widget.artistId.replaceFirst('tidal:', '');
|
|
final metadata = await PlatformBridge.getTidalMetadata(
|
|
'artist',
|
|
tidalArtistId,
|
|
);
|
|
final albumsList = metadata['albums'] as List<dynamic>;
|
|
albums = albumsList
|
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
.toList();
|
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
|
|
headerImage = artistInfo?['images'] as String?;
|
|
} else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
|
final result = await PlatformBridge.getArtistWithExtension(
|
|
widget.extensionId!,
|
|
widget.artistId,
|
|
);
|
|
|
|
if (result == null) {
|
|
throw Exception('Failed to load artist from extension');
|
|
}
|
|
|
|
final artistData = result;
|
|
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();
|
|
}
|
|
|
|
headerImage =
|
|
artistData['header_image'] as String? ??
|
|
artistData['cover_url'] as String? ??
|
|
artistData['image_url'] as String?;
|
|
listeners = 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>;
|
|
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?;
|
|
listeners = artistData['listeners'] as int?;
|
|
} else {
|
|
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(
|
|
url,
|
|
);
|
|
final albumsList = metadata['albums'] as List<dynamic>;
|
|
albums = albumsList
|
|
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
|
.toList();
|
|
}
|
|
}
|
|
|
|
final finalHeaderImage =
|
|
headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
|
|
final finalListeners =
|
|
listeners ?? _monthlyListeners ?? widget.monthlyListeners;
|
|
|
|
_ArtistCache.set(
|
|
widget.artistId,
|
|
albums: albums,
|
|
releases: releases,
|
|
topTracks: topTracks,
|
|
headerImageUrl: finalHeaderImage,
|
|
monthlyListeners: finalListeners,
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_albums = albums;
|
|
_releases = releases;
|
|
_topTracks = topTracks;
|
|
_headerImageUrl = finalHeaderImage;
|
|
_monthlyListeners = finalListeners;
|
|
_isLoadingDiscography = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoadingDiscography = 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: data['album_artist']?.toString() ?? widget.artistName,
|
|
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?,
|
|
releaseDate: data['release_date']?.toString(),
|
|
albumType: data['album_type']?.toString() ?? album?.albumType,
|
|
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
|
source: data['provider_id']?.toString() ?? widget.extensionId,
|
|
);
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
|
|
return PopScope(
|
|
canPop: !_isSelectionMode,
|
|
onPopInvokedWithResult: (didPop, result) {
|
|
if (!didPop && _isSelectionMode) {
|
|
_exitSelectionMode();
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
CustomScrollView(
|
|
controller: _scrollController,
|
|
slivers: [
|
|
_buildHeader(
|
|
context,
|
|
colorScheme,
|
|
albums: albums,
|
|
hasDiscography: hasDiscography,
|
|
),
|
|
if (_isLoadingDiscography)
|
|
const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
),
|
|
if (_error != null)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: _buildErrorWidget(_error!, colorScheme),
|
|
),
|
|
),
|
|
if (!_isLoadingDiscography && _error == null) ...[
|
|
if (_topTracks != null && _topTracks!.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildPopularSection(colorScheme),
|
|
),
|
|
if (releases.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildAlbumSection(
|
|
'Releases',
|
|
releases,
|
|
colorScheme,
|
|
),
|
|
),
|
|
if (albumsOnly.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildAlbumSection(
|
|
context.l10n.artistAlbums,
|
|
albumsOnly,
|
|
colorScheme,
|
|
),
|
|
),
|
|
if (singles.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildAlbumSection(
|
|
context.l10n.artistSingles,
|
|
singles,
|
|
colorScheme,
|
|
showTypeBadge: true,
|
|
),
|
|
),
|
|
if (compilations.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: _buildAlbumSection(
|
|
context.l10n.artistCompilations,
|
|
compilations,
|
|
colorScheme,
|
|
),
|
|
),
|
|
],
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
|
),
|
|
],
|
|
),
|
|
if (_isSelectionMode)
|
|
_buildSelectionBar(context, colorScheme, albums),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _exitSelectionMode() {
|
|
HapticFeedback.lightImpact();
|
|
setState(() {
|
|
_isSelectionMode = false;
|
|
_selectedAlbumIds.clear();
|
|
});
|
|
}
|
|
|
|
void _enterSelectionMode(String albumId) {
|
|
HapticFeedback.mediumImpact();
|
|
setState(() {
|
|
_isSelectionMode = true;
|
|
_selectedAlbumIds.add(albumId);
|
|
});
|
|
}
|
|
|
|
void _toggleAlbumSelection(String albumId) {
|
|
HapticFeedback.selectionClick();
|
|
setState(() {
|
|
if (_selectedAlbumIds.contains(albumId)) {
|
|
_selectedAlbumIds.remove(albumId);
|
|
if (_selectedAlbumIds.isEmpty) {
|
|
_isSelectionMode = false;
|
|
}
|
|
} else {
|
|
_selectedAlbumIds.add(albumId);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _selectAll(List<ArtistAlbum> albums) {
|
|
setState(() {
|
|
_selectedAlbumIds.addAll(albums.map((a) => a.id));
|
|
});
|
|
}
|
|
|
|
void _deselectAll() {
|
|
setState(() {
|
|
_selectedAlbumIds.clear();
|
|
});
|
|
}
|
|
|
|
Widget _buildSelectionBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
List<ArtistAlbum> allAlbums,
|
|
) {
|
|
final allSelected = _selectedAlbumIds.length == allAlbums.length;
|
|
final selectedCount = _selectedAlbumIds.length;
|
|
final selectedAlbums = allAlbums
|
|
.where((a) => _selectedAlbumIds.contains(a.id))
|
|
.toList();
|
|
final totalTracks = selectedAlbums.fold<int>(
|
|
0,
|
|
(sum, a) => sum + a.totalTracks,
|
|
);
|
|
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
|
final compactLayout =
|
|
MediaQuery.sizeOf(context).width < 430 || textScale > 1.15;
|
|
|
|
return Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHigh,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.15),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: compactLayout
|
|
? Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: _exitSelectionMode,
|
|
icon: const Icon(Icons.close),
|
|
tooltip: context.l10n.dialogCancel,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
context.l10n.discographySelectedCount(
|
|
selectedCount,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
if (selectedCount > 0)
|
|
Text(
|
|
context.l10n.tracksCount(totalTracks),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: allSelected
|
|
? _deselectAll
|
|
: () => _selectAll(allAlbums),
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
allSelected
|
|
? context.l10n.actionDeselect
|
|
: context.l10n.actionSelectAll,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: selectedCount > 0
|
|
? () => _downloadSelectedAlbums(
|
|
context,
|
|
selectedAlbums,
|
|
)
|
|
: null,
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
context.l10n.discographyDownloadSelected,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
: Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: _exitSelectionMode,
|
|
icon: const Icon(Icons.close),
|
|
tooltip: context.l10n.dialogCancel,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
context.l10n.discographySelectedCount(
|
|
selectedCount,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
if (selectedCount > 0)
|
|
Text(
|
|
context.l10n.tracksCount(totalTracks),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: allSelected
|
|
? _deselectAll
|
|
: () => _selectAll(allAlbums),
|
|
child: Text(
|
|
allSelected
|
|
? context.l10n.actionDeselect
|
|
: context.l10n.actionSelectAll,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
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);
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
useRootNavigator: true,
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
|
),
|
|
builder: (context) => SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.download, color: colorScheme.primary),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
context.l10n.discographyDownload,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: 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(context, albums);
|
|
},
|
|
),
|
|
if (albumsOnly.isNotEmpty)
|
|
_DiscographyOptionTile(
|
|
icon: Icons.album,
|
|
title: context.l10n.discographyAlbumsOnly,
|
|
subtitle: context.l10n.discographyAlbumsOnlySubtitle(
|
|
albumTracks,
|
|
albumsOnly.length,
|
|
),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_downloadAlbums(context, 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(context, singles);
|
|
},
|
|
),
|
|
_DiscographyOptionTile(
|
|
icon: Icons.checklist,
|
|
title: context.l10n.discographySelectAlbums,
|
|
subtitle: context.l10n.discographySelectAlbumsSubtitle,
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
_enterSelectionMode(albums.first.id);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _downloadAlbums(
|
|
BuildContext context,
|
|
List<ArtistAlbum> albums,
|
|
) async {
|
|
final settings = ref.read(settingsProvider);
|
|
if (settings.askQualityBeforeDownload) {
|
|
DownloadServicePicker.show(
|
|
context,
|
|
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> _fetchAndQueueAlbums(
|
|
List<ArtistAlbum> albums,
|
|
String service,
|
|
String? qualityOverride,
|
|
) async {
|
|
if (_isFetchingDiscography) return;
|
|
|
|
setState(() => _isFetchingDiscography = true);
|
|
|
|
if (!mounted) {
|
|
setState(() => _isFetchingDiscography = false);
|
|
return;
|
|
}
|
|
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (ctx) => _FetchingProgressDialog(
|
|
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++;
|
|
|
|
// Update progress dialog
|
|
if (mounted) {
|
|
_FetchingProgressDialog.updateProgress(
|
|
context,
|
|
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;
|
|
}
|
|
|
|
// Check which tracks are already downloaded
|
|
final historyState = ref.read(downloadHistoryProvider);
|
|
final tracksToQueue = <Track>[];
|
|
int skippedCount = 0;
|
|
|
|
for (final track in allTracks) {
|
|
final isDownloaded =
|
|
historyState.isDownloaded(track.id) ||
|
|
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null);
|
|
|
|
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: SnackBarAction(
|
|
label: context.l10n.snackbarViewQueue,
|
|
onPressed: () {
|
|
// Navigate to queue tab (index 1)
|
|
// This will be handled by the navigation system
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
|
|
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
|
final result = await PlatformBridge.getAlbumWithExtension(
|
|
album.providerId!,
|
|
album.id,
|
|
);
|
|
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();
|
|
}
|
|
} else if (album.id.startsWith('deezer:')) {
|
|
final deezerId = album.id.replaceFirst('deezer:', '');
|
|
final metadata = await PlatformBridge.getDeezerMetadata(
|
|
'album',
|
|
deezerId,
|
|
);
|
|
if (metadata['tracks'] != null) {
|
|
final tracksList = metadata['tracks'] as List<dynamic>;
|
|
return tracksList
|
|
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
|
|
.toList();
|
|
}
|
|
} else if (album.id.startsWith('qobuz:')) {
|
|
final qobuzId = album.id.replaceFirst('qobuz:', '');
|
|
final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId);
|
|
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 if (album.id.startsWith('tidal:')) {
|
|
final tidalId = album.id.replaceFirst('tidal:', '');
|
|
final metadata = await PlatformBridge.getTidalMetadata('album', tidalId);
|
|
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();
|
|
}
|
|
|
|
// Fallback to direct Spotify metadata
|
|
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
|
if (metadata['tracks'] != null) {
|
|
final tracksList = metadata['tracks'] as List<dynamic>;
|
|
return tracksList
|
|
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
|
|
.toList();
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
|
|
int durationMs = 0;
|
|
final durationValue = data['duration'];
|
|
if (durationValue is int) {
|
|
durationMs = durationValue * 1000; // Deezer returns seconds
|
|
} else if (durationValue is double) {
|
|
durationMs = (durationValue * 1000).toInt();
|
|
}
|
|
|
|
return Track(
|
|
id: 'deezer:${data['id']}',
|
|
name: (data['title'] ?? data['name'] ?? '').toString(),
|
|
artistName:
|
|
(data['artist']?['name'] ?? data['artist'] ?? widget.artistName)
|
|
.toString(),
|
|
albumName: album.name,
|
|
albumArtist: widget.artistName,
|
|
artistId: widget.artistId,
|
|
albumId: album.id.isNotEmpty ? album.id : null,
|
|
coverUrl: album.coverUrl,
|
|
isrc: data['isrc']?.toString(),
|
|
duration: (durationMs / 1000).round(),
|
|
trackNumber:
|
|
data['track_position'] as int? ?? data['track_number'] as int?,
|
|
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
|
|
releaseDate: album.releaseDate,
|
|
albumType: album.albumType,
|
|
totalTracks: album.totalTracks,
|
|
);
|
|
}
|
|
|
|
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? listenersText;
|
|
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
|
if (listeners != null && listeners > 0) {
|
|
final formatter = NumberFormat.compact();
|
|
listenersText = context.l10n.artistMonthlyListeners(
|
|
formatter.format(listeners),
|
|
);
|
|
}
|
|
|
|
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 (hasValidImage)
|
|
CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
fit: BoxFit.cover,
|
|
alignment: Alignment.topCenter,
|
|
memCacheWidth: 800,
|
|
cacheManager: CoverCacheManager.instance,
|
|
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),
|
|
colorScheme.surface,
|
|
],
|
|
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.withValues(alpha: 0.8),
|
|
shadows: [
|
|
Shadow(
|
|
offset: const Offset(0, 1),
|
|
blurRadius: 2,
|
|
color: Colors.black.withValues(
|
|
alpha: 0.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
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,
|
|
StretchMode.blurBackground,
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPopularSection(ColorScheme colorScheme) {
|
|
if (_topTracks == null || _topTracks!.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final tracks = _topTracks!;
|
|
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,
|
|
);
|
|
}).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,
|
|
) {
|
|
return Consumer(
|
|
builder: (context, ref, child) {
|
|
final queueItem = ref.watch(
|
|
downloadQueueLookupProvider.select(
|
|
(lookup) => lookup.byTrackId[track.id],
|
|
),
|
|
);
|
|
|
|
final isInHistory = ref.watch(
|
|
downloadHistoryProvider.select((state) {
|
|
if (state.isDownloaded(track.id)) return true;
|
|
final isrc = track.isrc?.trim();
|
|
if (isrc != null &&
|
|
isrc.isNotEmpty &&
|
|
state.getByIsrc(isrc) != null) {
|
|
return true;
|
|
}
|
|
return state.findByTrackAndArtist(track.name, track.artistName) !=
|
|
null;
|
|
}),
|
|
);
|
|
|
|
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),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: track.coverUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: track.coverUrl!,
|
|
width: 48,
|
|
height: 48,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 96,
|
|
cacheManager: CoverCacheManager.instance,
|
|
placeholder: (context, url) => 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),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.tertiaryContainer,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.folder_outlined,
|
|
size: 10,
|
|
color: colorScheme.onTertiaryContainer,
|
|
),
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
context.l10n.libraryInLibrary,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w500,
|
|
color: colorScheme.onTertiaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
TrackCollectionQuickActions(track: track),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _handlePopularTrackTap(Track track, {required bool isQueued}) async {
|
|
if (isQueued) return;
|
|
|
|
final playedLocal = await _playLocalIfAvailable(track);
|
|
if (playedLocal) {
|
|
return;
|
|
}
|
|
|
|
_downloadTrack(track);
|
|
}
|
|
|
|
Future<bool> _playLocalIfAvailable(Track track) async {
|
|
final localState = ref.read(localLibraryProvider);
|
|
final historyState = ref.read(downloadHistoryProvider);
|
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
|
|
|
try {
|
|
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
|
|
track.id,
|
|
);
|
|
final isrc = track.isrc?.trim();
|
|
historyItem ??= (isrc != null && isrc.isNotEmpty)
|
|
? historyNotifier.getByIsrc(isrc)
|
|
: null;
|
|
historyItem ??= historyState.findByTrackAndArtist(
|
|
track.name,
|
|
track.artistName,
|
|
);
|
|
|
|
if (historyItem != null) {
|
|
final exists = await fileExists(historyItem.filePath);
|
|
if (exists) {
|
|
await ref
|
|
.read(playbackProvider.notifier)
|
|
.playLocalPath(
|
|
path: historyItem.filePath,
|
|
title: track.name,
|
|
artist: track.artistName,
|
|
album: track.albumName,
|
|
coverUrl: track.coverUrl ?? '',
|
|
);
|
|
return true;
|
|
}
|
|
historyNotifier.removeFromHistory(historyItem.id);
|
|
}
|
|
|
|
var localItem = (isrc != null && isrc.isNotEmpty)
|
|
? localState.getByIsrc(isrc)
|
|
: null;
|
|
localItem ??= localState.findByTrackAndArtist(
|
|
track.name,
|
|
track.artistName,
|
|
);
|
|
|
|
if (localItem != null && await fileExists(localItem.filePath)) {
|
|
await ref
|
|
.read(playbackProvider.notifier)
|
|
.playLocalPath(
|
|
path: localItem.filePath,
|
|
title: localItem.trackName,
|
|
artist: localItem.artistName,
|
|
album: localItem.albumName,
|
|
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
|
|
);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (settings.askQualityBeforeDownload) {
|
|
DownloadServicePicker.show(
|
|
context,
|
|
onSelect: (quality, service) {
|
|
if (!mounted) return;
|
|
enqueue(service, quality: quality);
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
enqueue(settings.defaultService);
|
|
}
|
|
|
|
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 = _selectedAlbumIds.contains(album.id);
|
|
|
|
return Semantics(
|
|
button: true,
|
|
selected: _isSelectionMode && isSelected,
|
|
label: _isSelectionMode
|
|
? 'Select album ${album.name}'
|
|
: 'Open album ${album.name}',
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
if (_isSelectionMode) {
|
|
_toggleAlbumSelection(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: [
|
|
Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: album.coverUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: album.coverUrl!,
|
|
width: tileSize,
|
|
height: tileSize,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: (tileSize * 2).round(),
|
|
cacheManager: CoverCacheManager.instance,
|
|
placeholder: (context, url) => Container(
|
|
width: tileSize,
|
|
height: tileSize,
|
|
color: colorScheme.surfaceContainerHighest,
|
|
),
|
|
errorWidget: (context, url, error) => Container(
|
|
width: tileSize,
|
|
height: tileSize,
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.album,
|
|
color: colorScheme.onSurfaceVariant,
|
|
size: 40,
|
|
),
|
|
),
|
|
)
|
|
: Container(
|
|
width: tileSize,
|
|
height: tileSize,
|
|
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: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.surface.withValues(alpha: 0.9),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.outline,
|
|
width: 2,
|
|
),
|
|
),
|
|
child: isSelected
|
|
? Icon(
|
|
Icons.check,
|
|
color: colorScheme.onPrimary,
|
|
size: 18,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
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' ? 'EP' : 'Single',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
album.name,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
album.totalTracks > 0
|
|
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
|
: album.releaseDate.length >= 4
|
|
? album.releaseDate.substring(0, 4)
|
|
: album.releaseDate,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _navigateToAlbum(ArtistAlbum album) {
|
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
|
|
|
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ExtensionAlbumScreen(
|
|
extensionId: album.providerId!,
|
|
albumId: album.id,
|
|
albumName: album.name,
|
|
coverUrl: album.coverUrl,
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => AlbumScreen(
|
|
albumId: album.id,
|
|
albumName: album.name,
|
|
coverUrl: album.coverUrl,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
|
final isRateLimit =
|
|
error.contains('429') ||
|
|
error.toLowerCase().contains('rate limit') ||
|
|
error.toLowerCase().contains('too many requests');
|
|
|
|
if (isRateLimit) {
|
|
return Card(
|
|
elevation: 0,
|
|
color: colorScheme.errorContainer,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.timer_off, color: colorScheme.onErrorContainer),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.errorRateLimited,
|
|
style: TextStyle(
|
|
color: colorScheme.onErrorContainer,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
context.l10n.errorRateLimitedMessage,
|
|
style: TextStyle(
|
|
color: colorScheme.onErrorContainer,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Card(
|
|
elevation: 0,
|
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: colorScheme.error),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Option tile for discography download bottom sheet
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Progress dialog shown while fetching album tracks
|
|
class _FetchingProgressDialog extends StatefulWidget {
|
|
final int totalAlbums;
|
|
final VoidCallback onCancel;
|
|
|
|
const _FetchingProgressDialog({
|
|
required this.totalAlbums,
|
|
required this.onCancel,
|
|
});
|
|
|
|
// Static method to update progress from outside
|
|
static void updateProgress(BuildContext context, int current, int total) {
|
|
final state = context
|
|
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
|
state?._updateProgress(current, total);
|
|
}
|
|
|
|
@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),
|
|
// Progress bar
|
|
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|