mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
916 lines
31 KiB
Dart
916 lines
31 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
|
import 'package:spotiflac_android/theme/app_tokens.dart';
|
|
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
|
import 'package:spotiflac_android/theme/cover_palette.dart';
|
|
import 'package:spotiflac_android/screens/track_history_snapshot.dart';
|
|
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:cached_network_image/cached_network_image.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/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
import 'package:spotiflac_android/utils/cover_art_utils.dart';
|
|
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
|
import 'package:spotiflac_android/screens/selection_mode_mixin.dart';
|
|
import 'package:spotiflac_android/widgets/error_card.dart';
|
|
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
|
import 'package:spotiflac_android/widgets/album_description.dart';
|
|
import 'package:spotiflac_android/utils/editorial_notes.dart';
|
|
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
|
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
|
import 'package:spotiflac_android/utils/ttl_cache.dart';
|
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
|
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
|
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/track_list_tile.dart';
|
|
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
|
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
|
|
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
|
|
import 'package:spotiflac_android/widgets/selection_action_button.dart';
|
|
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
|
|
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
|
|
import 'package:spotiflac_android/widgets/disc_separator_chip.dart';
|
|
import 'package:spotiflac_android/widgets/mornye_artist_header.dart';
|
|
|
|
class _AlbumCache {
|
|
static final _cache = TtlCache<({List<Track> tracks, String? description})>(
|
|
const Duration(minutes: 10),
|
|
maxEntries: 40,
|
|
);
|
|
|
|
static ({List<Track> tracks, String? description})? get(String key) =>
|
|
_cache.get(key);
|
|
|
|
static void set(String key, List<Track> tracks, String? description) =>
|
|
_cache.set(key, (tracks: tracks, description: description));
|
|
}
|
|
|
|
class AlbumScreen extends ConsumerStatefulWidget {
|
|
final String albumId;
|
|
final String albumName;
|
|
final String? coverUrl;
|
|
final String? headerVideoUrl;
|
|
final String? headerImageUrl;
|
|
final List<String>? audioTraits;
|
|
final List<Track>? tracks;
|
|
final String? extensionId;
|
|
final String? artistId;
|
|
final String? artistName;
|
|
final String? description;
|
|
|
|
const AlbumScreen({
|
|
super.key,
|
|
required this.albumId,
|
|
required this.albumName,
|
|
this.coverUrl,
|
|
this.headerVideoUrl,
|
|
this.headerImageUrl,
|
|
this.audioTraits,
|
|
this.tracks,
|
|
this.extensionId,
|
|
this.artistId,
|
|
this.artistName,
|
|
this.description,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
|
}
|
|
|
|
class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
|
with
|
|
SelectionModeMixin<AlbumScreen>,
|
|
CollapsingHeaderScrollMixin<AlbumScreen> {
|
|
final _historySnapshot = TrackHistorySnapshot();
|
|
List<Track>? _tracks;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
String? _artistId;
|
|
String? _albumType;
|
|
int? _albumTotalTracks;
|
|
String? _headerVideoUrl;
|
|
String? _headerImageUrl;
|
|
List<String> _audioTraits = const [];
|
|
String? _description;
|
|
|
|
String get _cacheKey =>
|
|
'${_effectiveMetadataProviderIdFromAlbumId()}:${widget.albumId}';
|
|
|
|
String _effectiveMetadataProviderIdFromAlbumId() {
|
|
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
|
return widget.extensionId!;
|
|
}
|
|
return resolveEffectiveMetadataProvider(
|
|
legacyProviderIdFromResourceId(widget.albumId) ?? 'spotify',
|
|
ref.read(extensionProvider),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final providerId = _effectiveMetadataProviderIdFromAlbumId();
|
|
ref
|
|
.read(recentAccessProvider.notifier)
|
|
.recordAlbumAccess(
|
|
id: widget.albumId,
|
|
name: widget.albumName,
|
|
artistName:
|
|
widget.artistName ??
|
|
widget.tracks?.firstOrNull?.albumArtist ??
|
|
widget.tracks?.firstOrNull?.artistName,
|
|
imageUrl: widget.coverUrl,
|
|
providerId: providerId,
|
|
);
|
|
});
|
|
|
|
final cached = _AlbumCache.get(_cacheKey);
|
|
_description = widget.description ?? cached?.description;
|
|
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
|
_tracks = widget.tracks;
|
|
} else {
|
|
_tracks = cached?.tracks;
|
|
}
|
|
_artistId = widget.artistId;
|
|
_albumType = _tracks?.firstOrNull?.albumType;
|
|
_albumTotalTracks = _tracks?.firstOrNull?.totalTracks;
|
|
_headerVideoUrl = widget.headerVideoUrl;
|
|
_headerImageUrl = widget.headerImageUrl;
|
|
_audioTraits = widget.audioTraits ?? const [];
|
|
|
|
if (_tracks == null || _tracks!.isEmpty) {
|
|
_fetchTracks();
|
|
}
|
|
}
|
|
|
|
Future<void> _fetchTracks() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final directProviderId = _directMetadataProviderId();
|
|
if (directProviderId != null) {
|
|
final metadata = await PlatformBridge.getProviderMetadata(
|
|
directProviderId,
|
|
'album',
|
|
_metadataResourceId(directProviderId),
|
|
);
|
|
_applyAlbumMetadata(
|
|
(metadata['track_list'] ?? metadata['tracks']) as List<dynamic>,
|
|
metadata['album_info'] as Map<String, dynamic>? ?? metadata,
|
|
);
|
|
return;
|
|
} else {
|
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
|
final result = await PlatformBridge.handleURLWithExtension(url);
|
|
if (result == null || result['tracks'] == null) {
|
|
throw StateError('Failed to load album metadata from extension');
|
|
}
|
|
|
|
_applyAlbumMetadata(
|
|
result['tracks'] as List<dynamic>,
|
|
result['album'] as Map<String, dynamic>?,
|
|
fallbackSource: result,
|
|
);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = context.friendlyError(e);
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parses tracks + header metadata from either the direct-provider payload
|
|
/// (`albumInfo` only) or the extension payload (`albumInfo` falling back to
|
|
/// top-level [fallbackSource] keys), then updates state and the TTL cache.
|
|
void _applyAlbumMetadata(
|
|
List<dynamic> trackList,
|
|
Map<String, dynamic>? albumInfo, {
|
|
Map<String, dynamic>? fallbackSource,
|
|
}) {
|
|
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
|
|
?.toString();
|
|
final albumType = normalizeOptionalString(
|
|
albumInfo?['album_type']?.toString(),
|
|
);
|
|
final totalTracks = albumInfo?['total_tracks'] as int?;
|
|
final headerVideo =
|
|
(albumInfo?['header_video'] ?? fallbackSource?['header_video'])
|
|
?.toString();
|
|
final headerImage =
|
|
(albumInfo?['header_image'] ?? fallbackSource?['header_image'])
|
|
?.toString();
|
|
final audioTraits =
|
|
((albumInfo?['audio_traits'] ?? fallbackSource?['audio_traits'])
|
|
as List?)
|
|
?.map((e) => e.toString())
|
|
.toList();
|
|
final tracks = trackList
|
|
.map(
|
|
(t) => _parseTrack(
|
|
t as Map<String, dynamic>,
|
|
albumTypeFallback: albumType,
|
|
totalTracksFallback: totalTracks,
|
|
).copyWith(headerVideoUrl: normalizeRemoteHttpUrl(headerVideo)),
|
|
)
|
|
.toList();
|
|
|
|
final description =
|
|
albumDescriptionFromMetadata(albumInfo) ??
|
|
albumDescriptionFromMetadata(fallbackSource);
|
|
_AlbumCache.set(_cacheKey, tracks, description);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_tracks = tracks;
|
|
_description = description;
|
|
_artistId = artistId;
|
|
_albumType = albumType;
|
|
_albumTotalTracks = totalTracks;
|
|
_headerVideoUrl = (headerVideo != null && headerVideo.isNotEmpty)
|
|
? headerVideo
|
|
: _headerVideoUrl;
|
|
_headerImageUrl = (headerImage != null && headerImage.isNotEmpty)
|
|
? headerImage
|
|
: _headerImageUrl;
|
|
_audioTraits = (audioTraits != null && audioTraits.isNotEmpty)
|
|
? audioTraits
|
|
: _audioTraits;
|
|
_error = null;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
String? _directMetadataProviderId() {
|
|
final providerId = _effectiveMetadataProviderIdFromAlbumId();
|
|
return providerId.isEmpty ? null : providerId;
|
|
}
|
|
|
|
String _metadataResourceId(String providerId) {
|
|
return stripPrefixedResourceId(widget.albumId);
|
|
}
|
|
|
|
static final _qualitySeparators = RegExp(r'[\s_-]+');
|
|
|
|
static (String, bool?) _qualitySignature(Track track) => (
|
|
(track.audioQuality ?? '').trim().toLowerCase().replaceAll(
|
|
_qualitySeparators,
|
|
'',
|
|
),
|
|
(track.audioModes?.trim().isEmpty ?? true) ? null : track.isDolbyAtmos,
|
|
);
|
|
|
|
Track? _commonQualityTrack(List<Track> tracks) {
|
|
final counts = <(String, bool?), int>{};
|
|
for (final track in tracks) {
|
|
final signature = _qualitySignature(track);
|
|
if (signature.$1.isEmpty && signature.$2 != true) continue;
|
|
counts.update(signature, (count) => count + 1, ifAbsent: () => 1);
|
|
}
|
|
// A strict majority avoids choosing an arbitrary baseline for mixed albums.
|
|
// Unknown metadata counts towards the total, but never implies a quality.
|
|
for (final track in tracks) {
|
|
if ((counts[_qualitySignature(track)] ?? 0) > tracks.length / 2) {
|
|
return track;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
List<Widget> _audioTraitInline({Track? commonQualityTrack}) {
|
|
final traits = _audioTraits
|
|
.map((t) => t.toLowerCase().trim())
|
|
.where((t) => t.isNotEmpty)
|
|
.toSet();
|
|
bool has(List<String> keys) => keys.any(traits.contains);
|
|
|
|
final items = <Widget>[];
|
|
if ((commonQualityTrack == null
|
|
? null
|
|
: _qualitySignature(commonQualityTrack).$2) ??
|
|
has(['atmos', 'dolby_atmos', 'dolby-atmos'])) {
|
|
items.add(
|
|
Builder(
|
|
builder: (context) => DolbyAtmosLogo(
|
|
color: HeaderPalette.of(context).onSurface,
|
|
size: const Size(18, 12),
|
|
),
|
|
),
|
|
);
|
|
} else if (has(['spatial'])) {
|
|
items.add(HeaderMetaItem('Spatial Audio', icon: Icons.surround_sound));
|
|
}
|
|
|
|
if (has(['hi-res-lossless', 'hi_res_lossless', 'hires-lossless'])) {
|
|
items.add(HeaderMetaItem('Hi-Res Lossless', icon: Icons.graphic_eq));
|
|
} else if (has(['lossless'])) {
|
|
items.add(HeaderMetaItem('Lossless', icon: Icons.graphic_eq));
|
|
}
|
|
|
|
final quality = commonQualityTrack?.audioQuality?.trim();
|
|
if (quality != null &&
|
|
quality.isNotEmpty &&
|
|
!items.whereType<HeaderMetaItem>().any(
|
|
(item) => item.label.toLowerCase() == quality.toLowerCase(),
|
|
)) {
|
|
items.add(HeaderMetaItem(quality));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
Widget _buildHeaderMeta(
|
|
BuildContext context,
|
|
String? releaseDate,
|
|
Track? commonQualityTrack,
|
|
) {
|
|
final items = <Widget>[];
|
|
|
|
final genre = _tracks?.firstOrNull?.genre?.trim();
|
|
if (context.isMornye && genre != null && genre.isNotEmpty) {
|
|
items.add(HeaderMetaItem(genre));
|
|
}
|
|
final year = _releaseYear(releaseDate);
|
|
if (year != null) items.add(HeaderMetaItem(year));
|
|
items.addAll(_audioTraitInline(commonQualityTrack: commonQualityTrack));
|
|
|
|
return ConstrainedBox(
|
|
constraints: const BoxConstraints(minHeight: 20),
|
|
child: HeaderMetaRow(items: items),
|
|
);
|
|
}
|
|
|
|
String? _releaseYear(String? date) {
|
|
if (date == null || date.isEmpty) return null;
|
|
final match = RegExp(r'(\d{4})').firstMatch(date);
|
|
return match?.group(1);
|
|
}
|
|
|
|
Track _parseTrack(
|
|
Map<String, dynamic> data, {
|
|
String? albumTypeFallback,
|
|
int? totalTracksFallback,
|
|
}) {
|
|
return Track(
|
|
id: data['spotify_id'] as String? ?? '',
|
|
name: data['name'] as String? ?? '',
|
|
artistName: data['artists'] as String? ?? '',
|
|
albumName: data['album_name'] as String? ?? '',
|
|
albumArtist: data['album_artist'] as String?,
|
|
artistId:
|
|
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
|
albumId: data['album_id']?.toString() ?? widget.albumId,
|
|
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
|
isrc: data['isrc'] as String?,
|
|
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
|
trackNumber: data['track_number'] as int?,
|
|
discNumber: data['disc_number'] as int?,
|
|
totalDiscs: data['total_discs'] as int?,
|
|
releaseDate: data['release_date'] as String?,
|
|
albumType:
|
|
normalizeOptionalString(data['album_type']?.toString()) ??
|
|
albumTypeFallback ??
|
|
_albumType,
|
|
totalTracks:
|
|
data['total_tracks'] as int? ??
|
|
totalTracksFallback ??
|
|
_albumTotalTracks,
|
|
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(),
|
|
previewUrl: data['preview_url']?.toString(),
|
|
explicit: parseExplicitFlag(data['explicit']),
|
|
);
|
|
}
|
|
|
|
String? _recommendedDownloadService() {
|
|
return _directMetadataProviderId();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (context.isMornye) {
|
|
return MornyeArtistSurface(
|
|
imageSource:
|
|
widget.coverUrl ?? _headerImageUrl ?? widget.headerImageUrl,
|
|
neutralActions: true,
|
|
child: Builder(builder: _buildPage),
|
|
);
|
|
}
|
|
return _buildPage(context);
|
|
}
|
|
|
|
Widget _buildPage(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final tracks = _tracks ?? [];
|
|
final commonQualityTrack = context.isMornye
|
|
? null
|
|
: _commonQualityTrack(tracks);
|
|
final pageBackgroundColor = colorScheme.surface;
|
|
final bottomPadding = isSelectionMode
|
|
? MediaQuery.paddingOf(context).bottom
|
|
: 0.0;
|
|
|
|
pruneSelection({
|
|
for (var index = 0; index < tracks.length; index++)
|
|
_trackSelectionId(tracks[index], index),
|
|
});
|
|
|
|
return CollectionScaffold(
|
|
scrollController: scrollController,
|
|
isSelectionMode: isSelectionMode,
|
|
onExitSelectionMode: exitSelectionMode,
|
|
backgroundColor: pageBackgroundColor,
|
|
selectionBar: _buildSelectionBottomBar(
|
|
context,
|
|
colorScheme,
|
|
tracks,
|
|
bottomPadding,
|
|
),
|
|
appBar: _buildAppBar(
|
|
context,
|
|
colorScheme,
|
|
pageBackgroundColor,
|
|
commonQualityTrack,
|
|
),
|
|
slivers: [
|
|
if (_description != null)
|
|
SliverToBoxAdapter(
|
|
child: AlbumDescription(
|
|
title: widget.albumName,
|
|
description: _description!,
|
|
),
|
|
),
|
|
if (_isLoading)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: context.isMornye
|
|
? EdgeInsets.zero
|
|
: const EdgeInsets.all(16),
|
|
child: const AlbumTrackListSkeleton(itemCount: 10),
|
|
),
|
|
),
|
|
if (_error != null)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: ErrorCard(
|
|
error: _error!,
|
|
colorScheme: colorScheme,
|
|
onRetry: _fetchTracks,
|
|
),
|
|
),
|
|
),
|
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
|
_buildTrackList(context, colorScheme, tracks, commonQualityTrack),
|
|
_buildAlbumFooter(context, colorScheme, tracks),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
Color pageBackgroundColor,
|
|
Track? commonQualityTrack,
|
|
) {
|
|
final tracks = _tracks ?? [];
|
|
final artistName =
|
|
widget.artistName ??
|
|
(tracks.isNotEmpty
|
|
? (tracks.first.albumArtist ?? tracks.first.artistName)
|
|
: null);
|
|
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
|
|
|
final motionUrl = _headerVideoUrl ?? widget.headerVideoUrl;
|
|
final hasMotion =
|
|
motionUrl != null &&
|
|
motionUrl.trim().isNotEmpty &&
|
|
Uri.tryParse(motionUrl)?.hasAuthority == true;
|
|
final coverThumbUrl = widget.coverUrl ?? _headerImageUrl;
|
|
final showSquareCover = context.isMornye || !hasMotion;
|
|
final expandedHeight = calculateExpandedHeight(context);
|
|
final cacheWidth = coverCacheWidthForViewport(context);
|
|
final headerBgUrl =
|
|
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl;
|
|
Widget headerBgImage() => headerBgUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: highResCoverUrl(headerBgUrl) ?? headerBgUrl,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheWidth,
|
|
cacheManager: CoverCacheManager.instance,
|
|
placeholder: (_, _) => Container(color: colorScheme.surface),
|
|
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
|
)
|
|
: Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.album,
|
|
size: 80,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
|
|
final Widget background = hasMotion
|
|
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage())
|
|
: headerBgImage();
|
|
|
|
return AlbumDetailHeader(
|
|
title: widget.albumName,
|
|
immersive: context.isMornye,
|
|
squareArtwork: !hasMotion && headerBgUrl == coverThumbUrl,
|
|
expandedHeight: expandedHeight,
|
|
showTitleInAppBar: showTitleInAppBar,
|
|
backgroundColor: pageBackgroundColor,
|
|
background: background,
|
|
paletteSource: coverThumbUrl,
|
|
blurAndScrimBackground: showSquareCover,
|
|
coverBuilder: showSquareCover
|
|
? (context, coverSize) => context.isMornye && hasMotion
|
|
? background
|
|
: coverThumbUrl != null
|
|
? DownloadableCover(
|
|
coverUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl,
|
|
baseName: [
|
|
if (artistName != null && artistName.isNotEmpty)
|
|
artistName,
|
|
widget.albumName,
|
|
].join(' - '),
|
|
child: CachedNetworkImage(
|
|
imageUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl,
|
|
fit: BoxFit.cover,
|
|
width: coverSize,
|
|
height: coverSize,
|
|
memCacheWidth: cacheWidth,
|
|
cacheManager: CoverCacheManager.instance,
|
|
placeholder: (_, _) =>
|
|
Container(color: colorScheme.surfaceContainerHighest),
|
|
errorWidget: (_, _, _) => Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.album,
|
|
size: 48,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.album,
|
|
size: 48,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
)
|
|
: null,
|
|
subtitle: (artistName != null && artistName.isNotEmpty)
|
|
? ClickableArtistName(
|
|
artistName: artistName,
|
|
artistId: _artistId,
|
|
coverUrl: widget.coverUrl,
|
|
extensionId: widget.extensionId,
|
|
style: TextStyle(
|
|
color: colorScheme.primary,
|
|
fontSize: context.tokens.headerSubtitleSize,
|
|
fontWeight: context.isMornye
|
|
? FontWeight.w400
|
|
: FontWeight.w600,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
meta: _buildHeaderMeta(context, releaseDate, commonQualityTrack),
|
|
actions: isSelectionMode
|
|
? null
|
|
: context.isMornye
|
|
? Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildLoveAllButton(),
|
|
const SizedBox(width: 16),
|
|
Flexible(
|
|
child: SizedBox(
|
|
width: 172,
|
|
child: HeaderFilledButton(
|
|
icon: CupertinoIcons.arrow_down_circle_fill,
|
|
label: context.l10n.dialogDownload,
|
|
onPressed: tracks.isEmpty
|
|
? null
|
|
: () => _downloadAll(context),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_buildAddToPlaylistButton(context),
|
|
],
|
|
)
|
|
: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildLoveAllButton(),
|
|
const SizedBox(width: 12),
|
|
Flexible(
|
|
child: HeaderFilledButton(
|
|
icon: Icons.download,
|
|
label: context.l10n.downloadAllCount(tracks.length),
|
|
onPressed: tracks.isEmpty
|
|
? null
|
|
: () => _downloadAll(context),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_buildAddToPlaylistButton(context),
|
|
],
|
|
),
|
|
appBarTitle: isSelectionMode
|
|
? context.l10n.selectionSelected(selectedIds.length)
|
|
: null,
|
|
leading: isSelectionMode
|
|
? HeaderCircleButton(
|
|
icon: Icons.close,
|
|
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
|
onPressed: exitSelectionMode,
|
|
)
|
|
: null,
|
|
appBarActions: isSelectionMode
|
|
? const []
|
|
: [
|
|
HeaderCircleButton(
|
|
icon: Icons.open_in_new_rounded,
|
|
tooltip: context.l10n.openInOtherServices,
|
|
onPressed: () => _showShareSheet(context, tracks, artistName),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAlbumFooter(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
List<Track> tracks,
|
|
) => buildTrackListFooter(context, colorScheme, tracks);
|
|
|
|
Widget _buildTrackList(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
List<Track> tracks,
|
|
Track? commonQualityTrack,
|
|
) {
|
|
_historySnapshot.update(tracks);
|
|
final historyLookups = _historySnapshot.lookups;
|
|
final existingHistoryKeys = ref.watch(
|
|
downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request),
|
|
);
|
|
int discNumber(Track track) {
|
|
final number = track.discNumber ?? 1;
|
|
return number > 0 ? number : 1;
|
|
}
|
|
|
|
final hasMultipleDiscs = tracks.any(
|
|
(track) => discNumber(track) > 1 || (track.totalDiscs ?? 1) > 1,
|
|
);
|
|
return SliverPadding(
|
|
padding: EdgeInsets.symmetric(horizontal: wideListInset(context)),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final track = tracks[index];
|
|
final selectionId = _trackSelectionId(track, index);
|
|
final isInHistory = existingHistoryKeys.contains(
|
|
historyLookups[index].lookupKey,
|
|
);
|
|
final row = KeyedSubtree(
|
|
key: ValueKey(selectionId),
|
|
child: StaggeredListItem(
|
|
index: index,
|
|
child: TrackListTile(
|
|
track: track,
|
|
isInHistory: isInHistory,
|
|
onDownload: ({bool forceQualityPicker = false}) =>
|
|
_downloadTrack(
|
|
context,
|
|
track,
|
|
forceQualityPicker: forceQualityPicker,
|
|
),
|
|
clickableArtist: true,
|
|
showQualityBadges: context.isMornye
|
|
? !_audioTraits.any(
|
|
(trait) => trait.toLowerCase().contains('lossless'),
|
|
)
|
|
: commonQualityTrack == null ||
|
|
_qualitySignature(track) !=
|
|
_qualitySignature(commonQualityTrack),
|
|
isSelectionMode: isSelectionMode,
|
|
isSelected: selectedIds.contains(selectionId),
|
|
onToggleSelection: () => toggleSelection(selectionId),
|
|
onEnterSelectionMode: () => enterSelectionMode(selectionId),
|
|
leading: SizedBox(
|
|
width: 32,
|
|
child: Center(
|
|
child: Text(
|
|
'${track.trackNumber ?? 0}',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
final disc = discNumber(track);
|
|
if (hasMultipleDiscs &&
|
|
(index == 0 || discNumber(tracks[index - 1]) != disc)) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DiscSeparatorChip(discNumber: disc),
|
|
row,
|
|
],
|
|
);
|
|
}
|
|
return row;
|
|
}, childCount: tracks.length),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _trackSelectionId(Track track, int index) =>
|
|
'$index|${track.discNumber ?? 0}|${track.trackNumber ?? 0}|'
|
|
'${track.id}|${track.name}';
|
|
|
|
List<Track> _selectedTracks(List<Track> tracks) => [
|
|
for (var index = 0; index < tracks.length; index++)
|
|
if (selectedIds.contains(_trackSelectionId(tracks[index], index)))
|
|
tracks[index],
|
|
];
|
|
|
|
Widget _buildSelectionBottomBar(
|
|
BuildContext barContext,
|
|
ColorScheme colorScheme,
|
|
List<Track> tracks,
|
|
double bottomPadding,
|
|
) {
|
|
final selectedCount = selectedIds.length;
|
|
final allSelected = tracks.isNotEmpty && selectedCount == tracks.length;
|
|
|
|
return SelectionBottomBar(
|
|
selectedCount: selectedCount,
|
|
allSelected: allSelected,
|
|
onClose: exitSelectionMode,
|
|
onToggleSelectAll: () {
|
|
if (allSelected) {
|
|
exitSelectionMode();
|
|
} else {
|
|
selectAll([
|
|
for (var index = 0; index < tracks.length; index++)
|
|
_trackSelectionId(tracks[index], index),
|
|
]);
|
|
}
|
|
},
|
|
bottomPadding: bottomPadding,
|
|
children: [
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: SelectionActionButton(
|
|
icon: Icons.download_rounded,
|
|
label: '${barContext.l10n.dialogDownload} ($selectedCount)',
|
|
onPressed: selectedCount == 0
|
|
? null
|
|
: () => _downloadSelected(context, tracks),
|
|
colorScheme: colorScheme,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _downloadSelected(
|
|
BuildContext context,
|
|
List<Track> tracks,
|
|
) async {
|
|
final selectedTracks = _selectedTracks(tracks);
|
|
if (selectedTracks.isEmpty) return;
|
|
|
|
await queueTracksSkippingDownloaded(
|
|
context,
|
|
ref,
|
|
selectedTracks,
|
|
artistNameForPicker: widget.albumName,
|
|
recommendedService: _recommendedDownloadService(),
|
|
forceQualityPicker: true,
|
|
onQueued: exitSelectionMode,
|
|
);
|
|
}
|
|
|
|
void _downloadTrack(
|
|
BuildContext context,
|
|
Track track, {
|
|
bool forceQualityPicker = false,
|
|
}) {
|
|
downloadSingleTrack(
|
|
context,
|
|
ref,
|
|
track,
|
|
recommendedService: _recommendedDownloadService(),
|
|
forceQualityPicker: forceQualityPicker,
|
|
);
|
|
}
|
|
|
|
Future<void> _downloadAll(BuildContext context) async {
|
|
final tracks = _tracks;
|
|
if (tracks == null || tracks.isEmpty) return;
|
|
await queueTracksSkippingDownloaded(
|
|
context,
|
|
ref,
|
|
tracks,
|
|
artistNameForPicker: widget.albumName,
|
|
recommendedService: _recommendedDownloadService(),
|
|
);
|
|
}
|
|
|
|
Widget _buildLoveAllButton() {
|
|
final collectionsState = ref.watch(libraryCollectionsProvider);
|
|
final tracks = _tracks;
|
|
final allLoved =
|
|
tracks != null &&
|
|
tracks.isNotEmpty &&
|
|
tracks.every((t) => collectionsState.isLoved(t));
|
|
|
|
return HeaderCircleButton(
|
|
icon: allLoved ? Icons.favorite : Icons.favorite_border,
|
|
tonal: true,
|
|
iconColor: allLoved ? Theme.of(context).colorScheme.error : null,
|
|
tooltip: allLoved
|
|
? context.l10n.trackOptionRemoveFromLoved
|
|
: context.l10n.tooltipLoveAll,
|
|
onPressed: tracks == null || tracks.isEmpty
|
|
? null
|
|
: () => _loveAll(tracks),
|
|
);
|
|
}
|
|
|
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
|
return HeaderCircleButton(
|
|
icon: Icons.add,
|
|
tonal: true,
|
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
|
onPressed: _tracks == null || _tracks!.isEmpty
|
|
? null
|
|
: () => showAddTracksToPlaylistSheet(context, ref, _tracks!),
|
|
);
|
|
}
|
|
|
|
void _showShareSheet(
|
|
BuildContext context,
|
|
List<Track> tracks,
|
|
String? artistName,
|
|
) {
|
|
final sourceExtensionId = _directMetadataProviderId() ?? '';
|
|
final resolvedArtists =
|
|
artistName ??
|
|
tracks.firstOrNull?.albumArtist ??
|
|
tracks.firstOrNull?.artistName ??
|
|
'';
|
|
|
|
CrossExtensionShareSheet.show(
|
|
context,
|
|
name: widget.albumName,
|
|
artists: resolvedArtists,
|
|
type: 'album',
|
|
sourceExtensionId: sourceExtensionId,
|
|
);
|
|
}
|
|
|
|
Future<void> _loveAll(List<Track> tracks) =>
|
|
loveAllTracks(context, ref, tracks);
|
|
}
|