mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-07 13:17:57 +02:00
526897b23b
Replaces the cropped BoxFit.cover header with a blurred cover backdrop plus the full square cover centered, so covers with baked-in text are no longer awkwardly cropped. Title, track count and actions now sit in one centered column that adapts to header height.
1087 lines
36 KiB
Dart
1087 lines
36 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:ui' show ImageFilter;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.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/library_collections_provider.dart';
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
|
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
|
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
|
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
|
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
|
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
|
|
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
|
final String playlistName;
|
|
final String? coverUrl;
|
|
final List<Track> tracks;
|
|
final String? playlistId;
|
|
final String? recommendedService;
|
|
|
|
const PlaylistScreen({
|
|
super.key,
|
|
required this.playlistName,
|
|
this.coverUrl,
|
|
required this.tracks,
|
|
this.playlistId,
|
|
this.recommendedService,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
|
}
|
|
|
|
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|
bool _showTitleInAppBar = false;
|
|
final ScrollController _scrollController = ScrollController();
|
|
List<Track>? _fetchedTracks;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
String? _resolvedPlaylistName;
|
|
String? _resolvedCoverUrl;
|
|
|
|
List<Track> get _tracks => _fetchedTracks ?? widget.tracks;
|
|
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
|
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
|
|
|
String? _legacyProviderIdFromResourceId(String value) {
|
|
if (value.startsWith('deezer:')) return 'deezer';
|
|
if (value.startsWith('qobuz:')) return 'qobuz';
|
|
if (value.startsWith('tidal:')) return 'tidal';
|
|
return null;
|
|
}
|
|
|
|
String _stripPrefixedResourceId(String value) {
|
|
final colonIndex = value.indexOf(':');
|
|
if (colonIndex <= 0 || colonIndex == value.length - 1) {
|
|
return value;
|
|
}
|
|
return value.substring(colonIndex + 1);
|
|
}
|
|
|
|
String? _metadataProviderId(String playlistId) {
|
|
final providerId = _legacyProviderIdFromResourceId(playlistId);
|
|
if (providerId == null) return null;
|
|
final effective = resolveEffectiveMetadataProvider(
|
|
providerId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
return effective.isEmpty ? null : effective;
|
|
}
|
|
|
|
String _metadataResourceId(String providerId, String playlistId) {
|
|
return _stripPrefixedResourceId(playlistId);
|
|
}
|
|
|
|
String? _recommendedDownloadService() {
|
|
final explicit = widget.recommendedService;
|
|
if (explicit != null && explicit.isNotEmpty) {
|
|
return explicit;
|
|
}
|
|
|
|
final playlistId = widget.playlistId;
|
|
if (playlistId != null) {
|
|
final providerId = _metadataProviderId(playlistId);
|
|
if (providerId != null && providerId.isNotEmpty) {
|
|
return resolveEffectiveDownloadService(
|
|
providerId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
}
|
|
}
|
|
|
|
final source = _tracks.firstOrNull?.source;
|
|
if (source != null && source.isNotEmpty) {
|
|
return source;
|
|
}
|
|
|
|
final trackId = _tracks.firstOrNull?.id ?? '';
|
|
final trackProviderId = _legacyProviderIdFromResourceId(trackId);
|
|
if (trackProviderId != null) {
|
|
return resolveEffectiveDownloadService(
|
|
trackProviderId,
|
|
ref.read(extensionProvider),
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
_fetchTracksIfNeeded();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _fetchTracksIfNeeded() async {
|
|
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
});
|
|
|
|
try {
|
|
String playlistId = widget.playlistId!;
|
|
late final Map<String, dynamic> result;
|
|
final providerId = _metadataProviderId(playlistId);
|
|
if (providerId != null) {
|
|
result = await PlatformBridge.getProviderMetadata(
|
|
providerId,
|
|
'playlist',
|
|
_metadataResourceId(providerId, playlistId),
|
|
);
|
|
} else {
|
|
result = await PlatformBridge.getProviderMetadata(
|
|
'deezer',
|
|
'playlist',
|
|
playlistId,
|
|
);
|
|
}
|
|
if (!mounted) return;
|
|
|
|
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
|
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
|
|
|
|
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
|
final tracks = trackList
|
|
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
|
.toList();
|
|
|
|
setState(() {
|
|
_fetchedTracks = tracks;
|
|
_resolvedPlaylistName = (playlistInfo?['name'] ?? owner?['name'])
|
|
?.toString();
|
|
_resolvedCoverUrl = (playlistInfo?['images'] ?? owner?['images'])
|
|
?.toString();
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Track _parseTrack(Map<String, dynamic> data) {
|
|
int durationMs = 0;
|
|
final durationValue = data['duration_ms'];
|
|
if (durationValue is int) {
|
|
durationMs = durationValue;
|
|
} else if (durationValue is double) {
|
|
durationMs = durationValue.toInt();
|
|
}
|
|
|
|
return Track(
|
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
|
name: (data['name'] ?? '').toString(),
|
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
|
albumArtist: data['album_artist']?.toString(),
|
|
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
|
albumId: data['album_id']?.toString(),
|
|
coverUrl: normalizeCoverReference(
|
|
(data['cover_url'] ?? data['images'])?.toString(),
|
|
),
|
|
isrc: data['isrc']?.toString(),
|
|
duration: (durationMs / 1000).round(),
|
|
trackNumber: data['track_number'] as int?,
|
|
discNumber: data['disc_number'] as int?,
|
|
totalDiscs: data['total_discs'] as int?,
|
|
releaseDate: data['release_date']?.toString(),
|
|
totalTracks: data['total_tracks'] as int?,
|
|
composer: data['composer']?.toString(),
|
|
audioQuality: data['audio_quality']?.toString(),
|
|
audioModes: data['audio_modes']?.toString(),
|
|
);
|
|
}
|
|
|
|
void _onScroll() {
|
|
final expandedHeight = _calculateExpandedHeight(context);
|
|
final shouldShow =
|
|
_scrollController.offset > (expandedHeight - kToolbarHeight - 20);
|
|
if (shouldShow != _showTitleInAppBar) {
|
|
setState(() => _showTitleInAppBar = shouldShow);
|
|
}
|
|
}
|
|
|
|
double _calculateExpandedHeight(BuildContext context) {
|
|
final mediaSize = MediaQuery.of(context).size;
|
|
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
|
}
|
|
|
|
String? _highResCoverUrl(String? url) {
|
|
if (url == null) return null;
|
|
if (url.contains('ab67616d00001e02')) {
|
|
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
|
}
|
|
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
|
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
|
return url.replaceAllMapped(
|
|
deezerRegex,
|
|
(m) => '/1000x1000-${m[3]}-${m[4]}-${m[5]}-${m[6]}.jpg',
|
|
);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
body: CustomScrollView(
|
|
controller: _scrollController,
|
|
slivers: [
|
|
_buildAppBar(context, colorScheme),
|
|
_buildInfoCard(context, colorScheme),
|
|
_buildTrackList(context, colorScheme),
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(height: 32 + context.navBarBottomInset),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
|
final expandedHeight = _calculateExpandedHeight(context);
|
|
|
|
return SliverAppBar(
|
|
expandedHeight: expandedHeight,
|
|
pinned: true,
|
|
stretch: true,
|
|
backgroundColor: colorScheme.surface,
|
|
surfaceTintColor: Colors.transparent,
|
|
title: AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 200),
|
|
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
|
child: Text(
|
|
_playlistName,
|
|
style: TextStyle(
|
|
color: colorScheme.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 16,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
flexibleSpace: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final collapseRatio =
|
|
(constraints.maxHeight - kToolbarHeight) /
|
|
(expandedHeight - kToolbarHeight);
|
|
final showContent = collapseRatio > 0.3;
|
|
final cacheWidth = coverCacheWidthForViewport(context);
|
|
|
|
return FlexibleSpaceBar(
|
|
collapseMode: CollapseMode.pin,
|
|
background: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (_coverUrl != null)
|
|
ImageFiltered(
|
|
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
|
|
child: CachedCoverImage(
|
|
imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheWidth,
|
|
placeholder: (_, _) =>
|
|
Container(color: colorScheme.surface),
|
|
errorWidget: (_, _, _) =>
|
|
Container(color: colorScheme.surface),
|
|
),
|
|
)
|
|
else
|
|
Container(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
child: Icon(
|
|
Icons.playlist_play,
|
|
size: 80,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
Container(color: Colors.black.withValues(alpha: 0.35)),
|
|
if (_coverUrl != null)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: expandedHeight * 0.65,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.black.withValues(alpha: 0.6),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: AnimatedOpacity(
|
|
duration: const Duration(milliseconds: 150),
|
|
opacity: showContent ? 1.0 : 0.0,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
20,
|
|
kToolbarHeight + 8,
|
|
20,
|
|
28,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (_coverUrl != null) ...[
|
|
Builder(
|
|
builder: (context) {
|
|
final coverSize =
|
|
(constraints.maxWidth * 0.5).clamp(
|
|
140.0,
|
|
220.0,
|
|
);
|
|
return Container(
|
|
width: coverSize,
|
|
height: coverSize,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(
|
|
alpha: 0.45,
|
|
),
|
|
blurRadius: 24,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: CachedCoverImage(
|
|
imageUrl:
|
|
_highResCoverUrl(_coverUrl) ??
|
|
_coverUrl!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: cacheWidth,
|
|
borderRadius: BorderRadius.circular(16),
|
|
placeholder: (_, _) => Container(
|
|
decoration: BoxDecoration(
|
|
color:
|
|
colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
errorWidget: (_, _, _) => Container(
|
|
decoration: BoxDecoration(
|
|
color:
|
|
colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
Text(
|
|
_playlistName,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
height: 1.2,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (_tracks.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 6,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.2),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.playlist_play,
|
|
size: 14,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
context.l10n.tracksCount(_tracks.length),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildLoveAllButton(),
|
|
const SizedBox(width: 12),
|
|
_buildDownloadAllCenterButton(context),
|
|
const SizedBox(width: 12),
|
|
_buildAddToPlaylistButton(context),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
stretchModes: const [StretchMode.zoomBackground],
|
|
);
|
|
},
|
|
),
|
|
leading: IconButton(
|
|
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.arrow_back, color: Colors.white),
|
|
),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
}
|
|
|
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
|
if (_isLoading) {
|
|
return const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: TrackListSkeleton(itemCount: 8),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_error != null) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Card(
|
|
color: colorScheme.errorContainer,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: colorScheme.error),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_error!,
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_tracks.isEmpty) {
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Center(
|
|
child: Text(
|
|
context.l10n.errorNoTracksFound,
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: _PlaylistTrackItem(
|
|
track: track,
|
|
isInHistory: isInHistory,
|
|
onDownload: () => _downloadTrack(context, track),
|
|
),
|
|
),
|
|
);
|
|
}, childCount: _tracks.length),
|
|
);
|
|
}
|
|
|
|
void _downloadTrack(BuildContext context, Track track) {
|
|
final settings = ref.read(settingsProvider);
|
|
|
|
if (settings.askQualityBeforeDownload) {
|
|
DownloadServicePicker.show(
|
|
context,
|
|
trackName: track.name,
|
|
artistName: track.artistName,
|
|
coverUrl: track.coverUrl,
|
|
recommendedService: _recommendedDownloadService(),
|
|
onSelect: (quality, service) {
|
|
ref
|
|
.read(downloadQueueProvider.notifier)
|
|
.addToQueue(
|
|
track,
|
|
service,
|
|
qualityOverride: quality,
|
|
playlistName: _playlistName,
|
|
);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} else {
|
|
final extensionState = ref.read(extensionProvider);
|
|
final service = resolveEffectiveDownloadService(
|
|
settings.defaultService,
|
|
extensionState,
|
|
);
|
|
if (service.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)),
|
|
);
|
|
return;
|
|
}
|
|
ref
|
|
.read(downloadQueueProvider.notifier)
|
|
.addToQueue(track, service, playlistName: _playlistName);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildCircleButton({
|
|
required IconData icon,
|
|
required String tooltip,
|
|
required VoidCallback? onPressed,
|
|
}) {
|
|
return Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.white.withValues(alpha: 0.15),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: IconButton(
|
|
onPressed: onPressed,
|
|
icon: Icon(icon, size: 22, color: Colors.white),
|
|
tooltip: tooltip,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLoveAllButton() {
|
|
final collectionsState = ref.watch(libraryCollectionsProvider);
|
|
final allLoved =
|
|
_tracks.isNotEmpty && _tracks.every((t) => collectionsState.isLoved(t));
|
|
|
|
return Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.white.withValues(alpha: 0.15),
|
|
border: Border.all(
|
|
color: Colors.white.withValues(alpha: 0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: IconButton(
|
|
onPressed: _tracks.isEmpty ? null : () => _loveAll(_tracks),
|
|
icon: Icon(
|
|
allLoved ? Icons.favorite : Icons.favorite_border,
|
|
size: 22,
|
|
color: allLoved ? Colors.redAccent : Colors.white,
|
|
),
|
|
tooltip: allLoved
|
|
? context.l10n.trackOptionRemoveFromLoved
|
|
: context.l10n.tooltipLoveAll,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDownloadAllCenterButton(BuildContext context) {
|
|
return FilledButton.icon(
|
|
onPressed: _tracks.isEmpty ? null : () => _confirmDownloadAll(context),
|
|
icon: const Icon(Icons.download_rounded, size: 18),
|
|
label: Text(context.l10n.downloadAllCount(_tracks.length)),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: Colors.black87,
|
|
minimumSize: const Size(0, 48),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAddToPlaylistButton(BuildContext context) {
|
|
return _buildCircleButton(
|
|
icon: Icons.playlist_add,
|
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
|
onPressed: _tracks.isEmpty
|
|
? null
|
|
: () => showAddTracksToPlaylistSheet(
|
|
context,
|
|
ref,
|
|
_tracks,
|
|
playlistNamePrefill: widget.playlistName,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _confirmDownloadAll(BuildContext context) {
|
|
if (_tracks.isEmpty) return;
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
final colorScheme = Theme.of(dialogContext).colorScheme;
|
|
return AlertDialog(
|
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
|
title: Text(context.l10n.dialogDownloadAllTitle),
|
|
content: Text(context.l10n.dialogDownloadAllMessage(_tracks.length)),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(context.l10n.dialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(dialogContext);
|
|
_downloadAll(context);
|
|
},
|
|
child: Text(context.l10n.dialogDownload),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _loveAll(List<Track> tracks) async {
|
|
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
|
final state = ref.read(libraryCollectionsProvider);
|
|
final allLoved = tracks.every((t) => state.isLoved(t));
|
|
|
|
if (allLoved) {
|
|
for (final track in tracks) {
|
|
final key = trackCollectionKey(track);
|
|
await notifier.removeFromLoved(key);
|
|
}
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.snackbarRemovedTracksFromLoved(tracks.length),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
int addedCount = 0;
|
|
for (final track in tracks) {
|
|
if (!state.isLoved(track)) {
|
|
await notifier.toggleLoved(track);
|
|
addedCount++;
|
|
}
|
|
}
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _downloadAll(BuildContext context) {
|
|
_downloadTracks(context, _tracks);
|
|
}
|
|
|
|
Future<void> _downloadTracks(BuildContext context, List<Track> tracks) async {
|
|
if (tracks.isEmpty) return;
|
|
|
|
final historyLookups = tracks
|
|
.map(historyLookupForTrack)
|
|
.toList(growable: false);
|
|
final existingHistoryKeys = await ref.read(
|
|
downloadHistoryBatchExistsProvider(
|
|
HistoryBatchLookupRequest(historyLookups),
|
|
).future,
|
|
);
|
|
if (!context.mounted) return;
|
|
final settings = ref.read(settingsProvider);
|
|
final localLibState =
|
|
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
|
? ref.read(localLibraryProvider)
|
|
: null;
|
|
final tracksToQueue = <Track>[];
|
|
int skippedCount = 0;
|
|
|
|
for (var i = 0; i < tracks.length; i++) {
|
|
final track = tracks[i];
|
|
final isInHistory = existingHistoryKeys.contains(
|
|
historyLookups[i].lookupKey,
|
|
);
|
|
final isInLocal =
|
|
localLibState?.existsInLibrary(
|
|
isrc: track.isrc,
|
|
trackName: track.name,
|
|
artistName: track.artistName,
|
|
) ??
|
|
false;
|
|
|
|
if (isInHistory || isInLocal) {
|
|
skippedCount++;
|
|
} else {
|
|
tracksToQueue.add(track);
|
|
}
|
|
}
|
|
|
|
if (tracksToQueue.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (settings.askQualityBeforeDownload) {
|
|
DownloadServicePicker.show(
|
|
context,
|
|
trackName: '${tracksToQueue.length} tracks',
|
|
artistName: _playlistName,
|
|
recommendedService: _recommendedDownloadService(),
|
|
onSelect: (quality, service) {
|
|
ref
|
|
.read(downloadQueueProvider.notifier)
|
|
.addMultipleToQueue(
|
|
tracksToQueue,
|
|
service,
|
|
qualityOverride: quality,
|
|
playlistName: _playlistName,
|
|
);
|
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
|
},
|
|
);
|
|
} else {
|
|
ref
|
|
.read(downloadQueueProvider.notifier)
|
|
.addMultipleToQueue(
|
|
tracksToQueue,
|
|
settings.defaultService,
|
|
playlistName: _playlistName,
|
|
);
|
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
|
}
|
|
}
|
|
|
|
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
|
final message = skipped > 0
|
|
? context.l10n.discographySkippedDownloaded(added, skipped)
|
|
: context.l10n.snackbarAddedTracksToQueue(added);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
}
|
|
|
|
class _PlaylistTrackItem extends ConsumerWidget {
|
|
final Track track;
|
|
final bool isInHistory;
|
|
final VoidCallback onDownload;
|
|
|
|
const _PlaylistTrackItem({
|
|
required this.track,
|
|
required this.isInHistory,
|
|
required this.onDownload,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
final queueItem = ref.watch(
|
|
downloadQueueLookupProvider.select(
|
|
(lookup) => lookup.byTrackId[track.id],
|
|
),
|
|
);
|
|
|
|
final showLocalLibraryIndicator = ref.watch(
|
|
settingsProvider.select(
|
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
|
),
|
|
);
|
|
final isInLocalLibrary = showLocalLibraryIndicator
|
|
? ref.watch(
|
|
localLibraryProvider.select(
|
|
(state) => state.existsInLibrary(
|
|
isrc: track.isrc,
|
|
trackName: track.name,
|
|
artistName: track.artistName,
|
|
),
|
|
),
|
|
)
|
|
: false;
|
|
|
|
final isQueued = queueItem != null;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Card(
|
|
elevation: 0,
|
|
color: Colors.transparent,
|
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
|
child: ListTile(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
leading: track.coverUrl != null
|
|
? CachedCoverImage(
|
|
imageUrl: track.coverUrl!,
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: BorderRadius.circular(8),
|
|
)
|
|
: Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.music_note,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
title: Text(
|
|
track.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
|
),
|
|
subtitle: Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
track.artistName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
),
|
|
),
|
|
...buildQualityBadges(
|
|
audioQuality: track.audioQuality,
|
|
audioModes: track.audioModes,
|
|
colorScheme: colorScheme,
|
|
),
|
|
if (isInLocalLibrary || isInHistory) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.folder_outlined,
|
|
size: 10,
|
|
color: colorScheme.onPrimaryContainer,
|
|
),
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
context.l10n.libraryInLibrary,
|
|
style: TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w500,
|
|
color: colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
trailing: TrackCollectionQuickActions(track: track),
|
|
onTap: () => _handleTap(context, ref, isQueued: isQueued),
|
|
onLongPress: () => TrackCollectionQuickActions.showTrackOptionsSheet(
|
|
context,
|
|
ref,
|
|
track,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _handleTap(
|
|
BuildContext context,
|
|
WidgetRef ref, {
|
|
required bool isQueued,
|
|
}) async {
|
|
if (isQueued) return;
|
|
|
|
final playedLocal = await _playLocalIfAvailable(context, ref);
|
|
if (playedLocal) {
|
|
return;
|
|
}
|
|
|
|
onDownload();
|
|
}
|
|
|
|
Future<bool> _playLocalIfAvailable(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
) async {
|
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
|
|
|
try {
|
|
DownloadHistoryItem? historyItem = await historyNotifier
|
|
.getBySpotifyIdAsync(track.id);
|
|
final isrc = track.isrc?.trim();
|
|
historyItem ??= (isrc != null && isrc.isNotEmpty)
|
|
? await historyNotifier.getByIsrcAsync(isrc)
|
|
: null;
|
|
historyItem ??= await historyNotifier.findByTrackAndArtistAsync(
|
|
track.name,
|
|
track.artistName,
|
|
);
|
|
|
|
if (historyItem != null) {
|
|
final exists = await fileExists(historyItem.filePath);
|
|
if (exists) {
|
|
await ref
|
|
.read(playbackProvider.notifier)
|
|
.playLocalPath(
|
|
path: historyItem.filePath,
|
|
title: track.name,
|
|
artist: track.artistName,
|
|
album: track.albumName,
|
|
coverUrl: track.coverUrl ?? '',
|
|
);
|
|
return true;
|
|
}
|
|
historyNotifier.removeFromHistory(historyItem.id);
|
|
}
|
|
|
|
final localItem = await ref
|
|
.read(localLibraryProvider.notifier)
|
|
.findExistingAsync(
|
|
isrc: isrc,
|
|
trackName: track.name,
|
|
artistName: track.artistName,
|
|
);
|
|
|
|
if (localItem != null && await fileExists(localItem.filePath)) {
|
|
await ref
|
|
.read(playbackProvider.notifier)
|
|
.playLocalPath(
|
|
path: localItem.filePath,
|
|
title: localItem.trackName,
|
|
artist: localItem.artistName,
|
|
album: localItem.albumName,
|
|
coverUrl: localItem.coverPath ?? track.coverUrl ?? '',
|
|
);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))),
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|