SpotiFLAC-Mobile/lib/screens/playlist_screen.dart
zarzet 149cdc782d refactor: migrate local library from in-memory list to database-backed pagination
Replace the full in-memory List<LocalLibraryItem> in LocalLibraryState
with a lightweight lookup index (ISRCs, matchKeys, filePathById) and
database-backed FutureProvider.family pagination providers.

Database changes:
- Add library schema v7 with normalized lookup columns (track_name_norm,
  artist_name_norm, album_name_norm, album_artist_norm, match_key,
  album_key) and corresponding indexes
- Backfill normalized columns on migration from v6
- Add getPage, getPageCount, getAlbumPage, getAlbumCount, getLookupIndex,
  getCoverPaths, getByFilePath, findFirstByTrackAndArtist DB methods

Provider changes:
- LocalLibraryState no longer holds items list; uses totalCount and
  loadedIndexVersion for change tracking
- Deprecate synchronous getByIsrc/findByTrackAndArtist (return null);
  add async findExistingAsync, getByIsrcAsync, getById on notifier
- Add localLibraryPageProvider, localLibraryAlbumPageProvider,
  localLibraryAllItemsProvider family providers for paginated access
- Add localLibraryCoverProvider and localLibraryFirstCoverProvider
  for async cover path resolution from DB

Screen migrations:
- album/artist/playlist screens use findExistingAsync for playback
- library_tracks_folder_screen uses async cover providers and
  existsInLibrary for local library indicator
- queue_tab watches localLibraryAllItemsProvider instead of state.items
- library_settings_page uses state.totalCount
- playback_provider uses findExistingAsync

Track metadata screen:
- Replace pushReplacement navigation with in-place state swap using
  AnimatedSwitcher for smooth cross-fade transitions on track swipe
- Add metadataLoadGeneration counter to prevent stale async callbacks
- Reset all transient state (lyrics, cover, file check) on track change
2026-05-06 03:15:30 +07:00

1009 lines
32 KiB
Dart

import 'package:flutter/material.dart';
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/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),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
);
}
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)
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,
),
),
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.85),
],
),
),
),
),
Positioned(
left: 20,
right: 20,
bottom: 40,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
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),
),
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: StaggeredListItem(
index: index,
child: _PlaylistTrackItem(
track: track,
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);
}
void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return;
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider)
: null;
final tracksToQueue = <Track>[];
int skippedCount = 0;
for (final track in tracks) {
final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
null;
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 VoidCallback onDownload;
const _PlaylistTrackItem({required this.track, 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 isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
}
return state.findByTrackAndArtist(track.name, track.artistName) != null;
}),
);
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.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3),
Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
],
),
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 historyState = ref.read(downloadHistoryProvider);
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
try {
DownloadHistoryItem? historyItem = historyNotifier.getBySpotifyId(
track.id,
);
final isrc = track.isrc?.trim();
historyItem ??= (isrc != null && isrc.isNotEmpty)
? historyNotifier.getByIsrc(isrc)
: null;
historyItem ??= historyState.findByTrackAndArtist(
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;
}
}