diff --git a/CHANGELOG.md b/CHANGELOG.md index 40296531..1ffa536a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,9 @@ # Changelog -## [Unreleased] - ## [3.1.3] - 2026-01-19 ### Added -- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory - - Cover images no longer disappear when app is closed or device restarts - - Cache stored in `app_flutter/cover_cache/` directory (not cleared by system) - - Maximum 1000 images cached for up to 365 days - - Covers are cached when displayed in History, Home, Album, Artist, or any other screen - - New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management - - **External LRC Lyrics File Support**: Option to save lyrics as separate .lrc files for compatibility with external music players - New "Lyrics Mode" setting in Settings > Download > Lyrics section - Three modes available: @@ -28,6 +19,13 @@ - Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3 - Respects "Ask quality before download" setting - uses default quality if disabled + - **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory + - Cover images no longer disappear when app is closed or device restarts + - Cache stored in `app_flutter/cover_cache/` directory (not cleared by system) + - Maximum 1000 images cached for up to 365 days + - Covers are cached when displayed in History, Home, Album, Artist, or any other screen + - New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management + - **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer - New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre` - Metadata fetched during `enrichTrack()` via Deezer album API diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ab7c7eaa..989cee9d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -26,6 +26,10 @@ String? _normalizeOptionalString(String? value) { return trimmed; } +final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]'); +final _trailingDotsRegex = RegExp(r'\.+$'); +final _yearRegex = RegExp(r'^(\d{4})'); + class DownloadHistoryItem { final String id; final String trackName; @@ -143,6 +147,7 @@ class DownloadHistoryState { class DownloadHistoryNotifier extends Notifier { static const _storageKey = 'download_history'; + final Future _prefs = SharedPreferences.getInstance(); bool _isLoaded = false; @override @@ -162,7 +167,7 @@ class DownloadHistoryNotifier extends Notifier { Future _loadFromStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonStr = prefs.getString(_storageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); @@ -223,7 +228,7 @@ class DownloadHistoryNotifier extends Notifier { Future _saveToStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonList = state.items.map((e) => e.toJson()).toList(); await prefs.setString(_storageKey, jsonEncode(jsonList)); _historyLog.d('Saved ${state.items.length} items to storage'); @@ -385,6 +390,7 @@ class DownloadQueueNotifier extends Notifier { static const _cleanupInterval = 50; static const _queueStorageKey = 'download_queue'; final NotificationService _notificationService = NotificationService(); + final Future _prefs = SharedPreferences.getInstance(); int _totalQueuedAtStart = 0; int _completedInSession = 0; int _failedInSession = 0; @@ -410,7 +416,7 @@ class DownloadQueueNotifier extends Notifier { _isLoaded = true; try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final jsonStr = prefs.getString(_queueStorageKey); if (jsonStr != null && jsonStr.isNotEmpty) { final List jsonList = jsonDecode(jsonStr); @@ -448,7 +454,7 @@ class DownloadQueueNotifier extends Notifier { Future _saveQueueToStorage() async { try { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _prefs; final pendingItems = state.items .where( @@ -783,15 +789,15 @@ class DownloadQueueNotifier extends Notifier { String _sanitizeFolderName(String name) { return name - .replaceAll(RegExp(r'[<>:"/\\|?*]'), '_') - .replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots + .replaceAll(_invalidFolderChars, '_') + .replaceAll(_trailingDotsRegex, '') // Remove trailing dots .trim(); } /// Extract year from release date (format: "2005-06-13" or "2005") String? _extractYear(String? releaseDate) { if (releaseDate == null || releaseDate.isEmpty) return null; - final match = RegExp(r'^(\d{4})').firstMatch(releaseDate); + final match = _yearRegex.firstMatch(releaseDate); return match?.group(1); } @@ -1216,7 +1222,13 @@ class DownloadQueueNotifier extends Notifier { } } - Future _embedMetadataToMp3(String mp3Path, Track track) async { + Future _embedMetadataToMp3( + String mp3Path, + Track track, { + String? genre, + String? label, + String? copyright, + }) async { final settings = ref.read(settingsProvider); String? coverPath; @@ -1283,6 +1295,19 @@ class DownloadQueueNotifier extends Notifier { metadata['ISRC'] = track.isrc!; } + if (genre != null && genre.isNotEmpty) { + metadata['GENRE'] = genre; + _log.d('Adding GENRE to MP3: $genre'); + } + if (label != null && label.isNotEmpty) { + metadata['ORGANIZATION'] = label; + _log.d('Adding ORGANIZATION (label) to MP3: $label'); + } + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + _log.d('Adding COPYRIGHT to MP3: $copyright'); + } + _log.d('MP3 Metadata map content: $metadata'); if (settings.embedLyrics) { @@ -1447,29 +1472,17 @@ class DownloadQueueNotifier extends Notifier { } final currentItems = state.items; - final nextItem = currentItems.firstWhere( + final nextIndex = currentItems.indexWhere( (item) => item.status == DownloadStatus.queued, - orElse: () => DownloadItem( - id: '', - track: const Track( - id: '', - name: '', - artistName: '', - albumName: '', - duration: 0, - ), - service: '', - createdAt: DateTime.now(), - ), ); - - if (nextItem.id.isEmpty) { + if (nextIndex == -1) { _log.d( 'No more items to process (checked ${currentItems.length} items)', ); break; } + final nextItem = currentItems[nextIndex]; _log.d( 'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})', ); @@ -1956,7 +1969,18 @@ class DownloadQueueNotifier extends Notifier { DownloadStatus.downloading, progress: 0.99, ); - await _embedMetadataToMp3(mp3Path, trackToDownload); + + final mp3BackendGenre = result['genre'] as String?; + final mp3BackendLabel = result['label'] as String?; + final mp3BackendCopyright = result['copyright'] as String?; + + await _embedMetadataToMp3( + mp3Path, + trackToDownload, + genre: mp3BackendGenre ?? genre, + label: mp3BackendLabel ?? label, + copyright: mp3BackendCopyright, + ); } else { _log.w('MP3 conversion failed, keeping FLAC file'); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 00cd98a6..2d89bc5e 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -46,16 +46,15 @@ class _HomeScreenState extends ConsumerState { } } - void _downloadTrack(int index) { - final trackState = ref.read(trackProvider); - if (index >= 0 && index < trackState.tracks.length) { - final track = trackState.tracks[index]; - final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added "${track.name}" to queue')), - ); - } + void _downloadTrack(Track track) { + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addToQueue( + track, + settings.defaultService, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added "${track.name}" to queue')), + ); } void _downloadAll() { @@ -89,8 +88,10 @@ class _HomeScreenState extends ConsumerState { @override Widget build(BuildContext context) { final trackState = ref.watch(trackProvider); - final queueState = ref.watch(downloadQueueProvider); + final queuedCount = + ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final colorScheme = Theme.of(context).colorScheme; + final tracks = trackState.tracks; return Scaffold( appBar: AppBar( @@ -146,13 +147,13 @@ class _HomeScreenState extends ConsumerState { if (trackState.albumName != null || trackState.playlistName != null) _buildHeader(trackState, colorScheme), - if (trackState.tracks.length > 1) + if (tracks.length > 1) Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: FilledButton.icon( onPressed: _downloadAll, icon: const Icon(Icons.download), - label: Text('Download All (${trackState.tracks.length})'), + label: Text('Download All (${tracks.length})'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), ), @@ -160,11 +161,12 @@ class _HomeScreenState extends ConsumerState { ), Expanded( - child: trackState.tracks.isEmpty + child: tracks.isEmpty ? _buildEmptyState(colorScheme) : ListView.builder( - itemCount: trackState.tracks.length, - itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), + itemCount: tracks.length, + itemBuilder: (context, index) => + _buildTrackTile(tracks[index], colorScheme), ), ), ], @@ -180,13 +182,13 @@ class _HomeScreenState extends ConsumerState { ), NavigationDestination( icon: Badge( - isLabelVisible: queueState.queuedCount > 0, - label: Text('${queueState.queuedCount}'), + isLabelVisible: queuedCount > 0, + label: Text('$queuedCount'), child: const Icon(Icons.queue_music_outlined), ), selectedIcon: Badge( - isLabelVisible: queueState.queuedCount > 0, - label: Text('${queueState.queuedCount}'), + isLabelVisible: queuedCount > 0, + label: Text('$queuedCount'), child: const Icon(Icons.queue_music), ), label: 'Queue', @@ -261,8 +263,7 @@ child: CachedNetworkImage( ); } - Widget _buildTrackTile(int index, ColorScheme colorScheme) { - final track = ref.watch(trackProvider).tracks[index]; + Widget _buildTrackTile(Track track, ColorScheme colorScheme) { final isCollection = track.isCollection; String subtitleText; @@ -318,7 +319,7 @@ child: CachedNetworkImage( color: colorScheme.onSurfaceVariant, ), ), - onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index), + onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track), ); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 7339bbba..af5413a3 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -548,7 +548,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (showRecentAccess) SliverToBoxAdapter( - child: _buildRecentAccess(recentAccessItems, colorScheme), + child: _buildRecentAccess( + recentAccessItems, + historyItems, + colorScheme, + ), ), SliverToBoxAdapter( @@ -666,9 +670,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ); } - Widget _buildRecentAccess(List items, ColorScheme colorScheme) { - final historyItems = ref.read(downloadHistoryProvider).items; - + Widget _buildRecentAccess( + List items, + List historyItems, + ColorScheme colorScheme, + ) { // Group download history by album final albumGroups = >{}; for (final h in historyItems) { diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart index aa38fad7..b649d496 100644 --- a/lib/screens/queue_screen.dart +++ b/lib/screens/queue_screen.dart @@ -11,20 +11,20 @@ class QueueScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final queueState = ref.watch(downloadQueueProvider); + final items = ref.watch(downloadQueueProvider.select((s) => s.items)); final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( title: Text(context.l10n.queueTitle), actions: [ - if (queueState.items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.delete_sweep), onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(), tooltip: context.l10n.queueClearCompleted, ), - if (queueState.items.isNotEmpty) + if (items.isNotEmpty) IconButton( icon: const Icon(Icons.clear_all), onPressed: () => _showClearAllDialog(context, ref), @@ -32,11 +32,12 @@ class QueueScreen extends ConsumerWidget { ), ], ), - body: queueState.items.isEmpty + body: items.isEmpty ? _buildEmptyState(context, colorScheme) : ListView.builder( - itemCount: queueState.items.length, - itemBuilder: (context, index) => _buildQueueItem(context, ref, queueState.items[index], colorScheme), + itemCount: items.length, + itemBuilder: (context, index) => + _buildQueueItem(context, ref, items[index], colorScheme), ), ); } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 88377d51..0788ecb9 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -2,6 +2,7 @@ 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/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -44,22 +45,22 @@ class _SearchScreenState extends ConsumerState { } } - void _downloadTrack(int index) { - final trackState = ref.read(trackProvider); - if (index >= 0 && index < trackState.tracks.length) { - final track = trackState.tracks[index]; - final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added "${track.name}" to queue')), - ); - } + void _downloadTrack(Track track) { + final settings = ref.read(settingsProvider); + ref.read(downloadQueueProvider.notifier).addToQueue( + track, + settings.defaultService, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added "${track.name}" to queue')), + ); } @override Widget build(BuildContext context) { final trackState = ref.watch(trackProvider); final colorScheme = Theme.of(context).colorScheme; + final tracks = trackState.tracks; return Scaffold( appBar: AppBar( @@ -96,11 +97,12 @@ class _SearchScreenState extends ConsumerState { ), ), Expanded( - child: trackState.tracks.isEmpty + child: tracks.isEmpty ? _buildEmptyState(colorScheme) : ListView.builder( - itemCount: trackState.tracks.length, - itemBuilder: (context, index) => _buildTrackTile(index, colorScheme), + itemCount: tracks.length, + itemBuilder: (context, index) => + _buildTrackTile(tracks[index], colorScheme), ), ), ], @@ -130,8 +132,7 @@ class _SearchScreenState extends ConsumerState { ); } - Widget _buildTrackTile(int index, ColorScheme colorScheme) { - final track = ref.watch(trackProvider).tracks[index]; + Widget _buildTrackTile(Track track, ColorScheme colorScheme) { return ListTile( leading: track.coverUrl != null ? ClipRRect( @@ -175,9 +176,9 @@ child: CachedNetworkImage( ), trailing: IconButton( icon: Icon(Icons.download, color: colorScheme.primary), - onPressed: () => _downloadTrack(index), + onPressed: () => _downloadTrack(track), ), - onTap: () => _downloadTrack(index), + onTap: () => _downloadTrack(track), ); } }