mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-31 00:07:25 +02:00
Albums/artists with a motion banner have no square cover, so the Hero had no destination: the open flight ended nowhere and there was no return flight. The Hero now rides the full-bleed banner, flying the still cover image as the shuttle so the video doesn't restart mid-air.
648 lines
22 KiB
Dart
648 lines
22 KiB
Dart
import 'package:flutter/material.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/widgets/error_card.dart';
|
|
import 'package:spotiflac_android/widgets/album_detail_header.dart';
|
|
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
|
import 'package:spotiflac_android/utils/provider_resource_ids.dart';
|
|
import 'package:spotiflac_android/utils/ttl_cache.dart';
|
|
import 'package:spotiflac_android/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';
|
|
|
|
class _AlbumCache {
|
|
static final _cache = TtlCache<List<Track>>(const Duration(minutes: 10));
|
|
|
|
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;
|
|
|
|
/// Shared-element tag for the header cover (see [AlbumDetailHeader.heroTag]).
|
|
final Object? heroTag;
|
|
|
|
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.heroTag,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<AlbumScreen> createState() => _AlbumScreenState();
|
|
}
|
|
|
|
class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
|
with CollapsingHeaderScrollMixin<AlbumScreen> {
|
|
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);
|
|
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 = e.toString();
|
|
_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,
|
|
),
|
|
)
|
|
.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;
|
|
_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 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(),
|
|
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) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final tracks = _tracks ?? [];
|
|
final pageBackgroundColor = colorScheme.surface;
|
|
final bottomInset = context.navBarBottomInset;
|
|
|
|
return Scaffold(
|
|
backgroundColor: pageBackgroundColor,
|
|
body: CustomScrollView(
|
|
controller: scrollController,
|
|
slivers: [
|
|
_buildAppBar(context, colorScheme, pageBackgroundColor),
|
|
if (_isLoading)
|
|
const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: AlbumTrackListSkeleton(itemCount: 10),
|
|
),
|
|
),
|
|
if (_error != null)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: ErrorCard(error: _error!, colorScheme: colorScheme),
|
|
),
|
|
),
|
|
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
|
|
_buildTrackList(context, colorScheme, tracks),
|
|
_buildAlbumFooter(context, colorScheme, tracks),
|
|
],
|
|
SliverToBoxAdapter(child: SizedBox(height: 32 + bottomInset)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 = !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,
|
|
),
|
|
);
|
|
|
|
Widget background = hasMotion
|
|
? MotionHeaderBanner(videoUrl: motionUrl, fallback: headerBgImage())
|
|
: headerBgImage();
|
|
// With a motion banner there is no square cover, so the Hero rides the
|
|
// full-bleed background instead; the shuttle flies the still image (a
|
|
// shuttle-built banner would restart the video mid-flight).
|
|
if (hasMotion && widget.heroTag != null) {
|
|
background = Hero(
|
|
tag: widget.heroTag!,
|
|
flightShuttleBuilder: (_, _, _, _, _) => headerBgImage(),
|
|
child: background,
|
|
);
|
|
}
|
|
|
|
return AlbumDetailHeader(
|
|
title: widget.albumName,
|
|
expandedHeight: expandedHeight,
|
|
showTitleInAppBar: showTitleInAppBar,
|
|
backgroundColor: pageBackgroundColor,
|
|
heroTag: widget.heroTag,
|
|
background: background,
|
|
blurAndScrimBackground: showSquareCover,
|
|
coverBuilder: showSquareCover
|
|
? (context, coverSize) => coverThumbUrl != null
|
|
? 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: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
meta: _buildHeaderMeta(context, releaseDate),
|
|
actions: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildLoveAllButton(),
|
|
const SizedBox(width: 12),
|
|
Flexible(
|
|
child: FilledButton.icon(
|
|
onPressed: tracks.isEmpty ? null : () => _downloadAll(context),
|
|
icon: const Icon(Icons.download, size: 18),
|
|
label: Text(
|
|
context.l10n.downloadAllCount(tracks.length),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: Colors.black87,
|
|
disabledBackgroundColor: Colors.white.withValues(alpha: 0.45),
|
|
disabledForegroundColor: Colors.black54,
|
|
minimumSize: const Size(0, 48),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_buildAddToPlaylistButton(context),
|
|
],
|
|
),
|
|
appBarActions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: IconButton(
|
|
tooltip: context.l10n.openInOtherServices,
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.open_in_new_rounded, color: Colors.white),
|
|
),
|
|
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,
|
|
) {
|
|
final historyLookups = tracks
|
|
.map(historyLookupForTrack)
|
|
.toList(growable: false);
|
|
final existingHistoryKeys = ref
|
|
.watch(
|
|
downloadHistoryBatchExistsProvider(
|
|
HistoryBatchLookupRequest(historyLookups),
|
|
),
|
|
)
|
|
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
|
|
return SliverList(
|
|
delegate: SliverChildBuilderDelegate((context, index) {
|
|
final track = tracks[index];
|
|
final isInHistory = existingHistoryKeys.contains(
|
|
historyLookups[index].lookupKey,
|
|
);
|
|
return KeyedSubtree(
|
|
key: ValueKey(track.id),
|
|
child: StaggeredListItem(
|
|
index: index,
|
|
child: TrackListTile(
|
|
track: track,
|
|
isInHistory: isInHistory,
|
|
onDownload: () => _downloadTrack(context, track),
|
|
clickableArtist: true,
|
|
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),
|
|
);
|
|
}
|
|
|
|
void _downloadTrack(BuildContext context, Track track) {
|
|
downloadSingleTrack(
|
|
context,
|
|
ref,
|
|
track,
|
|
recommendedService: _recommendedDownloadService(),
|
|
);
|
|
}
|
|
|
|
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,
|
|
iconColor: allLoved ? Colors.redAccent : Colors.white,
|
|
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,
|
|
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);
|
|
}
|