mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
Raise album details and artist actions over the artwork, soften the lower artist banner, and derive action colors from the artist logo. Match loading skeletons to the updated header layouts and preserve scrolling and large text behavior.
811 lines
27 KiB
Dart
811 lines
27 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/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/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/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/mornye_artist_header.dart';
|
|
|
|
class _AlbumCache {
|
|
static final _cache = TtlCache<List<Track>>(
|
|
const Duration(minutes: 10),
|
|
maxEntries: 40,
|
|
);
|
|
|
|
static List<Track>? get(String albumId) => _cache.get(albumId);
|
|
|
|
static void set(String albumId, List<Track> tracks) =>
|
|
_cache.set(albumId, tracks);
|
|
}
|
|
|
|
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;
|
|
|
|
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,
|
|
});
|
|
|
|
@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 _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,
|
|
);
|
|
});
|
|
|
|
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
|
|
_tracks = widget.tracks;
|
|
} else {
|
|
_tracks = _AlbumCache.get(widget.albumId);
|
|
}
|
|
_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'] as List<dynamic>,
|
|
metadata['album_info'] as Map<String, dynamic>?,
|
|
);
|
|
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();
|
|
|
|
_AlbumCache.set(widget.albumId, tracks);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_tracks = tracks;
|
|
_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);
|
|
}
|
|
|
|
List<Widget> _audioTraitInline() {
|
|
final traits = _audioTraits
|
|
.map((t) => t.toLowerCase().trim())
|
|
.where((t) => t.isNotEmpty)
|
|
.toSet();
|
|
if (traits.isEmpty) return const [];
|
|
|
|
bool has(List<String> keys) => keys.any(traits.contains);
|
|
|
|
final items = <Widget>[];
|
|
if (has(['atmos', 'dolby_atmos', 'dolby-atmos'])) {
|
|
items.add(HeaderMetaItem('Dolby Atmos', icon: Icons.surround_sound));
|
|
} 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));
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
Widget _buildHeaderMeta(BuildContext context, String? releaseDate) {
|
|
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());
|
|
|
|
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 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),
|
|
slivers: [
|
|
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),
|
|
_buildAlbumFooter(context, colorScheme, tracks),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBar(
|
|
BuildContext context,
|
|
ColorScheme colorScheme,
|
|
Color pageBackgroundColor,
|
|
) {
|
|
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),
|
|
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,
|
|
) {
|
|
_historySnapshot.update(tracks);
|
|
final historyLookups = _historySnapshot.lookups;
|
|
final existingHistoryKeys = ref.watch(
|
|
downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request),
|
|
);
|
|
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,
|
|
);
|
|
return 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'),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}, 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);
|
|
}
|