mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
Save album motion videos in app storage during downloads and prefer offline playback with an aspect-aware layout. Add cache management for saved videos. Center active lyrics on opening, expand lyrics during manual scrolling, and adjust landscape header spacing. Group search rows and skeletons with dividers and remove redundant result icons. Validation: Flutter analysis clean; 723 tests passed, 4 skipped; iOS simulator build installed.
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/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.isMornye ? 20 : 16,
|
|
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,
|
|
tonal: true,
|
|
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);
|
|
}
|