From 77e4457244992bc7044a62f19e9090c8e1185ca3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 22:55:53 +0700 Subject: [PATCH] feat: add persistent cover image cache - Add CoverCacheManager service for persistent image caching - Cache stored in app_flutter/cover_cache/ (not cleared by system) - Maximum 1000 images cached for up to 365 days - Update all 11 screens to use persistent cache manager - Add flutter_cache_manager and path dependencies - Update CHANGELOG.md with all changes for v3.1.3 --- CHANGELOG.md | 39 +++++ go_backend/deezer.go | 24 ++- lib/main.dart | 10 +- lib/providers/download_queue_provider.dart | 182 +++++++++++++++------ lib/providers/track_provider.dart | 3 + lib/screens/album_screen.dart | 14 +- lib/screens/artist_screen.dart | 16 +- lib/screens/downloaded_album_screen.dart | 55 ++++--- lib/screens/home_screen.dart | 7 +- lib/screens/home_tab.dart | 64 ++++++-- lib/screens/playlist_screen.dart | 14 +- lib/screens/queue_screen.dart | 4 +- lib/screens/queue_tab.dart | 157 +++++++++--------- lib/screens/search_screen.dart | 4 +- lib/screens/settings/about_page.dart | 4 +- lib/screens/track_metadata_screen.dart | 77 +++++---- lib/services/cover_cache_manager.dart | 114 +++++++++++++ lib/widgets/cached_cover_image.dart | 69 ++++++++ pubspec.lock | 4 +- pubspec.yaml | 4 +- pubspec_ios.yaml | 6 +- 21 files changed, 650 insertions(+), 221 deletions(-) create mode 100644 lib/services/cover_cache_manager.dart create mode 100644 lib/widgets/cached_cover_image.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index af3d6a6c..40296531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,18 @@ # 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: @@ -70,6 +79,27 @@ - `enrichTrack()` now returns all extended metadata to Go backend - Replaced all hardcoded User-Agent strings with `utils.randomUserAgent()` +### Performance + +- **Faster App Startup**: Notification, Share Intent, and Cover Cache Manager initialization now run in parallel +- **Download Queue Polling**: Batched progress updates reduce rebuilds and list allocations during active downloads +- **Queue Item Updates**: Status/progress updates now skip no-op changes and update by index for fewer allocations +- **Directory Creation**: Download output folders are created once per path, reducing repeated I/O for albums/singles +- **Search Results Rendering**: Single-pass filtering avoids repeated `indexOf` calls for large result sets +- **Queue Lookups in UI**: O(1) lookup for queue status in Home/Album/Playlist/Artist track lists +- **History Filtering**: Album/single counts and grouping are computed once per build +- **Downloaded Album View**: Tracks are grouped by disc in one pass to reduce filtering overhead +- **Track Metadata Screen**: + - Palette extraction deferred until after transition; reduced sample size for smoother navigation + - File stat uses a single syscall and only triggers state updates on change + - Static regex/month table avoids repeated allocations + - Cover precached before opening metadata from history/queue/recents + +### Backend + +- **Deezer ISRC Fetching**: Uses ISRCs already present in payloads and caches them, cutting extra API calls +- **SearchAll Allocation**: Preallocated slices to reduce allocations during Deezer search + ### Technical - **Go Backend Changes**: @@ -82,10 +112,19 @@ - `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse` - **Flutter Changes**: + - `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max) + - `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache + - `lib/main.dart`: Added `CoverCacheManager.initialize()` to app startup + - `lib/screens/*.dart`: All 11 screens updated to use persistent cache manager for CachedNetworkImage - `lib/providers/download_queue_provider.dart`: Updated `_embedMetadataAndCover()` to accept and embed genre, label, copyright; added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` - `lib/screens/track_metadata_screen.dart`: Display genre, label, copyright in metadata grid - `lib/l10n/arb/app_en.arb`: Added `trackGenre`, `trackLabel`, `trackCopyright` localization strings +### Dependencies + +- Added `flutter_cache_manager: ^3.4.1` (explicit dependency for persistent cache) +- Added `path: ^1.9.0` (for cache directory path handling) + --- ## [3.1.2] - 2026-01-19 diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 03ef7fdc..9b6ff111 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -201,8 +201,8 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, c.cacheMu.RUnlock() result := &SearchAllResult{ - Tracks: make([]TrackMetadata, 0), - Artists: make([]SearchArtistResult, 0), + Tracks: make([]TrackMetadata, 0, trackLimit), + Artists: make([]SearchArtistResult, 0, artistLimit), } // Search tracks - NO ISRC fetch for performance @@ -577,13 +577,24 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee // fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { - result := make(map[string]string) + result := make(map[string]string, len(tracks)) var resultMu sync.Mutex var tracksToFetch []deezerTrack + var directISRCs map[string]string c.cacheMu.RLock() for _, track := range tracks { trackIDStr := fmt.Sprintf("%d", track.ID) + if track.ISRC != "" { + result[trackIDStr] = track.ISRC + if _, ok := c.isrcCache[trackIDStr]; !ok { + if directISRCs == nil { + directISRCs = make(map[string]string) + } + directISRCs[trackIDStr] = track.ISRC + } + continue + } if isrc, ok := c.isrcCache[trackIDStr]; ok { result[trackIDStr] = isrc } else { @@ -591,6 +602,13 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr } } c.cacheMu.RUnlock() + if len(directISRCs) > 0 { + c.cacheMu.Lock() + for trackIDStr, isrc := range directISRCs { + c.isrcCache[trackIDStr] = isrc + } + c.cacheMu.Unlock() + } if len(tracksToFetch) == 0 { return result diff --git a/lib/main.dart b/lib/main.dart index 1a5439fd..ff3ec625 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,13 +7,15 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - - await NotificationService().initialize(); - - await ShareIntentService().initialize(); + await Future.wait([ + NotificationService().initialize(), + ShareIntentService().initialize(), + CoverCacheManager.initialize(), + ]); runApp( ProviderScope( diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index f3ce7c8a..e1e27bdc 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -372,6 +372,18 @@ class DownloadQueueState { items.where((i) => i.status == DownloadStatus.downloading).length; } +class _ProgressUpdate { + final DownloadStatus status; + final double progress; + final double? speedMBps; + + const _ProgressUpdate({ + required this.status, + required this.progress, + this.speedMBps, + }); +} + class DownloadQueueNotifier extends Notifier { Timer? _progressTimer; int _downloadCount = 0; // Counter for connection cleanup @@ -383,6 +395,7 @@ class DownloadQueueNotifier extends Notifier { int _completedInSession = 0; // Track completed downloads in current session int _failedInSession = 0; // Track failed downloads in current session bool _isLoaded = false; + final Set _ensuredDirs = {}; @override DownloadQueueState build() { @@ -475,6 +488,15 @@ class DownloadQueueNotifier extends Notifier { try { final allProgress = await PlatformBridge.getAllDownloadProgress(); final items = allProgress['items'] as Map? ?? {}; + final currentItems = state.items; + final itemsById = {}; + final itemIndexById = {}; + for (int i = 0; i < currentItems.length; i++) { + final item = currentItems[i]; + itemsById[item.id] = item; + itemIndexById[item.id] = i; + } + final progressUpdates = {}; bool hasFinalizingItem = false; String? finalizingTrackName; @@ -482,9 +504,7 @@ class DownloadQueueNotifier extends Notifier { for (final entry in items.entries) { final itemId = entry.key; - final localItem = state.items - .where((i) => i.id == itemId) - .firstOrNull; + final localItem = itemsById[itemId]; if (localItem == null) { continue; } @@ -506,16 +526,13 @@ class DownloadQueueNotifier extends Notifier { final status = itemProgress['status'] as String? ?? 'downloading'; if (status == 'finalizing' && bytesTotal > 0) { - updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); - - final currentItem = state.items - .where((i) => i.id == itemId) - .firstOrNull; - if (currentItem != null) { - hasFinalizingItem = true; - finalizingTrackName = currentItem.track.name; - finalizingArtistName = currentItem.track.artistName; - } + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.finalizing, + progress: 1.0, + ); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; continue; } @@ -530,7 +547,11 @@ class DownloadQueueNotifier extends Notifier { percentage = progressFromBackend; } - updateProgress(itemId, percentage, speedMBps: speedMBps); + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: percentage, + speedMBps: speedMBps, + ); final mbReceived = bytesReceived / (1024 * 1024); final mbTotal = bytesTotal / (1024 * 1024); @@ -546,6 +567,41 @@ class DownloadQueueNotifier extends Notifier { } } + if (progressUpdates.isNotEmpty) { + var updatedItems = currentItems; + bool changed = false; + + for (final entry in progressUpdates.entries) { + final index = itemIndexById[entry.key]; + if (index == null) continue; + final current = updatedItems[index]; + if (current.status == DownloadStatus.skipped || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed) { + continue; + } + final update = entry.value; + final next = current.copyWith( + status: update.status, + progress: update.progress, + speedMBps: update.speedMBps ?? current.speedMBps, + ); + if (current.status != next.status || + current.progress != next.progress || + current.speedMBps != next.speedMBps) { + if (!changed) { + updatedItems = List.from(updatedItems); + changed = true; + } + updatedItems[index] = next; + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + } + } + if (hasFinalizingItem && finalizingTrackName != null) { _notificationService.showDownloadFinalizing( trackName: finalizingTrackName, @@ -651,6 +707,20 @@ class DownloadQueueNotifier extends Notifier { } } + Future _ensureDirExists(String path, {String? label}) async { + if (_ensuredDirs.contains(path)) return; + final dir = Directory(path); + if (!await dir.exists()) { + await dir.create(recursive: true); + if (label != null) { + _log.d('Created $label: $path'); + } else { + _log.d('Created folder: $path'); + } + } + _ensuredDirs.add(path); + } + void setOutputDir(String dir) { state = state.copyWith(outputDir: dir); } @@ -665,11 +735,7 @@ class DownloadQueueNotifier extends Notifier { if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; - final dir = Directory(singlesPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created Singles folder: $singlesPath'); - } + await _ensureDirExists(singlesPath, label: 'Singles folder'); return singlesPath; } else { final albumName = _sanitizeFolderName(track.albumName); @@ -693,11 +759,7 @@ class DownloadQueueNotifier extends Notifier { albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; } - final dir = Directory(albumPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created Album folder: $albumPath'); - } + await _ensureDirExists(albumPath, label: 'Album folder'); return albumPath; } } @@ -725,11 +787,7 @@ class DownloadQueueNotifier extends Notifier { if (subPath.isNotEmpty) { final fullPath = '$baseDir${Platform.pathSeparator}$subPath'; - final dir = Directory(fullPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - _log.d('Created folder: $fullPath'); - } + await _ensureDirExists(fullPath); return fullPath; } @@ -824,21 +882,32 @@ class DownloadQueueNotifier extends Notifier { String? error, DownloadErrorType? errorType, }) { - final items = state.items.map((item) { - if (item.id == id) { - return item.copyWith( - status: status, - progress: progress ?? item.progress, - speedMBps: speedMBps ?? item.speedMBps, - filePath: filePath, - error: error, - errorType: errorType, - ); - } - return item; - }).toList(); + final items = state.items; + final index = items.indexWhere((item) => item.id == id); + if (index == -1) return; - state = state.copyWith(items: items); + final current = items[index]; + final next = current.copyWith( + status: status, + progress: progress ?? current.progress, + speedMBps: speedMBps ?? current.speedMBps, + filePath: filePath, + error: error, + errorType: errorType, + ); + + if (current.status == next.status && + current.progress == next.progress && + current.speedMBps == next.speedMBps && + current.filePath == next.filePath && + current.error == next.error && + current.errorType == next.errorType) { + return; + } + + final updatedItems = List.from(items); + updatedItems[index] = next; + state = state.copyWith(items: updatedItems); if (status == DownloadStatus.completed || status == DownloadStatus.failed || @@ -848,9 +917,11 @@ class DownloadQueueNotifier extends Notifier { } void updateProgress(String id, double progress, {double? speedMBps}) { - final item = state.items.where((i) => i.id == id).firstOrNull; - if (item == null || - item.status == DownloadStatus.skipped || + final items = state.items; + final index = items.indexWhere((i) => i.id == id); + if (index == -1) return; + final item = items[index]; + if (item.status == DownloadStatus.skipped || item.status == DownloadStatus.completed || item.status == DownloadStatus.failed) { return; @@ -2121,3 +2192,22 @@ final downloadQueueProvider = NotifierProvider( DownloadQueueNotifier.new, ); + +class DownloadQueueLookup { + final Map byTrackId; + + DownloadQueueLookup._(this.byTrackId); + + factory DownloadQueueLookup.fromItems(List items) { + final map = {}; + for (final item in items) { + map.putIfAbsent(item.track.id, () => item); + } + return DownloadQueueLookup._(map); + } +} + +final downloadQueueLookupProvider = Provider((ref) { + final items = ref.watch(downloadQueueProvider.select((s) => s.items)); + return DownloadQueueLookup.fromItems(items); +}); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 4534d400..cb0fad0b 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -488,6 +488,9 @@ class TrackNotifier extends Notifier { /// Set search text state for back button handling void setSearchText(bool hasText) { + if (state.hasSearchText == hasText) { + return; + } state = state.copyWith(hasSearchText: hasText); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 0e9125a3..5eede561 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.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/models/download_item.dart'; @@ -283,10 +284,11 @@ class _AlbumScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -521,9 +523,9 @@ class _AlbumTrackItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -545,8 +547,8 @@ class _AlbumTrackItem extends ConsumerWidget { margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - leading: track.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) +leading: track.coverUrl != null + ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance)) : 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: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 4e121f6a..7a7a35a4 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.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/models/download_item.dart'; @@ -355,12 +356,13 @@ return SliverAppBar( background: Stack( fit: StackFit.expand, children: [ - if (hasValidImage) +if (hasValidImage) CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, alignment: Alignment.topCenter, // Show top of image (faces) memCacheWidth: 800, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( color: colorScheme.surfaceContainerHighest, ), @@ -479,9 +481,9 @@ return SliverAppBar( /// Build a single popular track item with dynamic download status Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) { - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -515,12 +517,13 @@ return SliverAppBar( ClipRRect( borderRadius: BorderRadius.circular(4), child: track.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: 48, height: 48, @@ -751,12 +754,13 @@ return SliverAppBar( ClipRRect( borderRadius: BorderRadius.circular(8), child: album.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: album.coverUrl!, width: 140, height: 140, fit: BoxFit.cover, memCacheWidth: 280, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: 140, height: 140, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 477e4abc..830dd8c3 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -103,24 +104,15 @@ class _DownloadedAlbumScreenState extends ConsumerState { }); } - /// Get unique disc numbers from tracks (sorted) - List _getDiscNumbers(List tracks) { - final discNumbers = tracks - .map((t) => t.discNumber ?? 1) - .toSet() - .toList() - ..sort(); - return discNumbers; - } - - /// Check if album has multiple discs - bool _hasMultipleDiscs(List tracks) { - return _getDiscNumbers(tracks).length > 1; - } - - /// Get tracks for a specific disc - List _getTracksForDisc(List tracks, int discNumber) { - return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList(); + Map> _groupTracksByDisc( + List tracks, + ) { + final discMap = >{}; + for (final track in tracks) { + final discNumber = track.discNumber ?? 1; + discMap.putIfAbsent(discNumber, () => []).add(track); + } + return discMap; } void _enterSelectionMode(String itemId) { @@ -223,6 +215,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { } void _navigateToMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push(context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -231,6 +224,17 @@ class _DownloadedAlbumScreenState extends ConsumerState { )); } + void _precacheCover(String? url) { + if (url == null || url.isEmpty) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return; + } + precacheImage( + CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + context, + ); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -368,10 +372,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -501,8 +506,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { } Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { - // Check if album has multiple discs - if (!_hasMultipleDiscs(tracks)) { + final discMap = _groupTracksByDisc(tracks); + + // Single disc - use simple list + if (discMap.length <= 1) { // Single disc - use simple list return SliverList( delegate: SliverChildBuilderDelegate( @@ -519,12 +526,12 @@ class _DownloadedAlbumScreenState extends ConsumerState { } // Multiple discs - build list with separators - final discNumbers = _getDiscNumbers(tracks); + final discNumbers = discMap.keys.toList()..sort(); final List children = []; for (final discNumber in discNumbers) { - final discTracks = _getTracksForDisc(tracks, discNumber); - if (discTracks.isEmpty) continue; + final discTracks = discMap[discNumber]; + if (discTracks == null || discTracks.isEmpty) continue; // Add disc separator children.add(_buildDiscSeparator(context, colorScheme, discNumber)); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 65c61116..00cd98a6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.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'; @@ -210,11 +211,12 @@ class _HomeScreenState extends ConsumerState { if (state.coverUrl != null) ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: state.coverUrl!, width: 80, height: 80, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container( width: 80, height: 80, @@ -281,11 +283,12 @@ class _HomeScreenState extends ConsumerState { leading: track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index df6926fc..c692f6db 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/track_provider.dart'; @@ -637,13 +638,14 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ClipRRect( borderRadius: BorderRadius.circular(12), child: item.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, width: 100, height: 100, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, ) : Container( width: 100, @@ -845,12 +847,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ClipRRect( borderRadius: BorderRadius.circular(item.type == RecentAccessType.artist ? 28 : 4), child: item.imageUrl != null && item.imageUrl!.isNotEmpty - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.imageUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, + cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Container( width: 56, height: 56, @@ -977,6 +980,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } void _navigateToMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push(context, PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -985,6 +989,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } + void _precacheCover(String? url) { + if (url == null || url.isEmpty) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return; + } + precacheImage( + CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + context, + ); + } + /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || @@ -1059,10 +1074,28 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } - final realTracks = tracks.where((t) => !t.isCollection).toList(); - final albumItems = tracks.where((t) => t.isAlbumItem).toList(); - final playlistItems = tracks.where((t) => t.isPlaylistItem).toList(); - final artistItems = tracks.where((t) => t.isArtistItem).toList(); + final realTracks = []; + final realTrackIndexes = []; + final albumItems = []; + final playlistItems = []; + final artistItems = []; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks[i]; + if (!track.isCollection) { + realTracks.add(track); + realTrackIndexes.add(i); + } + if (track.isAlbumItem) { + albumItems.add(track); + } + if (track.isPlaylistItem) { + playlistItems.add(track); + } + if (track.isArtistItem) { + artistItems.add(track); + } + } return [ if (error != null) @@ -1205,9 +1238,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _TrackItemWithStatus( key: ValueKey(realTracks[i].id), track: realTracks[i], - index: tracks.indexOf(realTracks[i]), // Use original index for download + index: realTrackIndexes[i], showDivider: i < realTracks.length - 1, - onDownload: () => _downloadTrack(tracks.indexOf(realTracks[i])), + onDownload: () => _downloadTrack(realTrackIndexes[i]), ), ], ), @@ -1267,11 +1300,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), child: ClipOval( child: hasValidImage - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: artist.imageUrl!, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Icon( Icons.person, color: colorScheme.onSurfaceVariant, @@ -1701,9 +1735,9 @@ class _TrackItemWithStatus extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -1750,13 +1784,14 @@ class _TrackItemWithStatus extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: track.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: track.coverUrl!, width: thumbWidth, height: thumbHeight, fit: BoxFit.cover, memCacheWidth: (thumbWidth * 2).toInt(), memCacheHeight: (thumbHeight * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( width: thumbWidth, @@ -1929,13 +1964,14 @@ class _CollectionItemWidget extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(isArtist ? 28 : 10), child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, ) : Container( width: 56, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index aa76f696..a62af731 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.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/models/download_item.dart'; @@ -164,10 +165,11 @@ class _PlaylistScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: widget.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -323,9 +325,9 @@ class _PlaylistTrackItem extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final queueItem = ref.watch(downloadQueueProvider.select((state) { - return state.items.where((item) => item.track.id == track.id).firstOrNull; - })); + final queueItem = ref.watch( + downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), + ); final isInHistory = ref.watch(downloadHistoryProvider.select((state) { return state.isDownloaded(track.id); @@ -347,8 +349,8 @@ class _PlaylistTrackItem extends ConsumerWidget { margin: const EdgeInsets.symmetric(vertical: 2), child: ListTile( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - leading: track.coverUrl != null - ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96)) +leading: track.coverUrl != null + ? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance)) : 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: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant)), diff --git a/lib/screens/queue_screen.dart b/lib/screens/queue_screen.dart index cb604cd4..aa38fad7 100644 --- a/lib/screens/queue_screen.dart +++ b/lib/screens/queue_screen.dart @@ -1,6 +1,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/l10n/l10n.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -74,11 +75,12 @@ class QueueScreen extends ConsumerWidget { leading: item.track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: item.track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 2bf54f5f..1017cc29 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -31,6 +32,20 @@ class _GroupedAlbum { String get key => '$albumName|$artistName'; } +class _HistoryStats { + final Map albumCounts; + final List<_GroupedAlbum> groupedAlbums; + final int albumCount; + final int singleTracks; + + const _HistoryStats({ + required this.albumCounts, + required this.groupedAlbums, + required this.albumCount, + required this.singleTracks, + }); +} + class QueueTab extends ConsumerStatefulWidget { final PageController? parentPageController; final int parentPageIndex; @@ -234,6 +249,17 @@ class _QueueTabState extends ConsumerState { } } + void _precacheCover(String? url) { + if (url == null || url.isEmpty) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return; + } + precacheImage( + CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + context, + ); + } + void _navigateToMetadataScreen(DownloadItem item) { final historyItem = ref .read(downloadHistoryProvider) @@ -252,6 +278,7 @@ class _QueueTabState extends ConsumerState { ), ); + _precacheCover(historyItem.coverUrl); Navigator.push( context, PageRouteBuilder( @@ -266,6 +293,7 @@ class _QueueTabState extends ConsumerState { } void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { + _precacheCover(item.coverUrl); Navigator.push( context, PageRouteBuilder( @@ -285,15 +313,10 @@ class _QueueTabState extends ConsumerState { List _filterHistoryItems( List items, String filterMode, + Map albumCounts, ) { if (filterMode == 'all') return items; - final albumCounts = {}; - for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - albumCounts[key] = (albumCounts[key] ?? 0) + 1; - } - switch (filterMode) { case 'albums': return items.where((item) { @@ -312,82 +335,56 @@ class _QueueTabState extends ConsumerState { } } - /// Count albums vs singles for filter chips - Map _countAlbumsAndSingles(List items) { + _HistoryStats _buildHistoryStats(List items) { final albumCounts = {}; + final albumMap = >{}; for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; albumCounts[key] = (albumCounts[key] ?? 0) + 1; + albumMap.putIfAbsent(key, () => []).add(item); } - int albumTracks = 0; int singleTracks = 0; - for (final item in items) { final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - if ((albumCounts[key] ?? 0) > 1) { - albumTracks++; - } else { + if ((albumCounts[key] ?? 0) <= 1) { singleTracks++; } } - return {'albums': albumTracks, 'singles': singleTracks}; - } + final groupedAlbums = <_GroupedAlbum>[]; + albumMap.forEach((_, tracks) { + if (tracks.length <= 1) return; + tracks.sort((a, b) { + final aNum = a.trackNumber ?? 999; + final bNum = b.trackNumber ?? 999; + return aNum.compareTo(bNum); + }); - /// Group history items by album (for Albums filter view) - List<_GroupedAlbum> _groupByAlbum(List items) { - final albumMap = >{}; - - for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - albumMap.putIfAbsent(key, () => []).add(item); - } - - final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map( - (e) { - final tracks = e.value; - tracks.sort((a, b) { - final aNum = a.trackNumber ?? 999; - final bNum = b.trackNumber ?? 999; - return aNum.compareTo(bNum); - }); - - return _GroupedAlbum( - albumName: tracks.first.albumName, - artistName: tracks.first.albumArtist ?? tracks.first.artistName, - coverUrl: tracks.first.coverUrl, - tracks: tracks, - latestDownload: tracks - .map((t) => t.downloadedAt) - .reduce((a, b) => a.isAfter(b) ? a : b), - ); - }, - ).toList(); + groupedAlbums.add(_GroupedAlbum( + albumName: tracks.first.albumName, + artistName: tracks.first.albumArtist ?? tracks.first.artistName, + coverUrl: tracks.first.coverUrl, + tracks: tracks, + latestDownload: tracks + .map((t) => t.downloadedAt) + .reduce((a, b) => a.isAfter(b) ? a : b), + )); + }); groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload)); - return groupedAlbums; - } - - /// Count unique albums (for filter chip badge) - int _countUniqueAlbums(List items) { - final albumKeys = {}; - for (final item in items) { - final key = '${item.albumName}|${item.albumArtist ?? item.artistName}'; - albumKeys.add(key); + int albumCount = 0; + for (final count in albumCounts.values) { + if (count > 1) albumCount++; } - int count = 0; - for (final key in albumKeys) { - final trackCount = items - .where( - (i) => '${i.albumName}|${i.albumArtist ?? i.artistName}' == key, - ) - .length; - if (trackCount > 1) count++; - } - return count; + return _HistoryStats( + albumCounts: albumCounts, + groupedAlbums: groupedAlbums, + albumCount: albumCount, + singleTracks: singleTracks, + ); } void _navigateToDownloadedAlbum(_GroupedAlbum album) { @@ -435,11 +432,10 @@ class _QueueTabState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - final groupedAlbums = _groupByAlbum(allHistoryItems); - - final counts = _countAlbumsAndSingles(allHistoryItems); - final albumCount = _countUniqueAlbums(allHistoryItems); - final singleCount = counts['singles'] ?? 0; + final historyStats = _buildHistoryStats(allHistoryItems); + final groupedAlbums = historyStats.groupedAlbums; + final albumCount = historyStats.albumCount; + final singleCount = historyStats.singleTracks; final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -679,6 +675,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), _buildFilterContent( context: context, @@ -688,6 +685,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), _buildFilterContent( context: context, @@ -697,6 +695,7 @@ class _QueueTabState extends ConsumerState { historyViewMode: historyViewMode, queueItems: queueItems, groupedAlbums: groupedAlbums, + albumCounts: historyStats.albumCounts, ), ], ), @@ -713,7 +712,11 @@ class _QueueTabState extends ConsumerState { child: _buildSelectionBottomBar( context, colorScheme, - _filterHistoryItems(allHistoryItems, historyFilterMode), + _filterHistoryItems( + allHistoryItems, + historyFilterMode, + historyStats.albumCounts, + ), bottomPadding, ), ), @@ -731,8 +734,10 @@ class _QueueTabState extends ConsumerState { required String historyViewMode, required List queueItems, required List<_GroupedAlbum> groupedAlbums, + required Map albumCounts, }) { - final historyItems = _filterHistoryItems(allHistoryItems, filterMode); + final historyItems = + _filterHistoryItems(allHistoryItems, filterMode, albumCounts); return CustomScrollView( slivers: [ @@ -943,13 +948,14 @@ class _QueueTabState extends ConsumerState { ClipRRect( borderRadius: BorderRadius.circular(12), child: album.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: album.coverUrl!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, memCacheWidth: 300, memCacheHeight: 300, + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -1245,13 +1251,14 @@ class _QueueTabState extends ConsumerState { return item.track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: item.track.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, ), ) : Container( @@ -1404,11 +1411,12 @@ class _QueueTabState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: item.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, fit: BoxFit.cover, memCacheWidth: 200, memCacheHeight: 200, + cacheManager: CoverCacheManager.instance, ) : Container( color: colorScheme.surfaceContainerHighest, @@ -1613,13 +1621,14 @@ class _QueueTabState extends ConsumerState { item.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: item.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, memCacheWidth: 112, memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 39789ddf..88377d51 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,6 +1,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/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -135,11 +136,12 @@ class _SearchScreenState extends ConsumerState { leading: track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, ), ) : Container( diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 2840df6a..3df2dd2c 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -333,11 +334,12 @@ class _ContributorItem extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: CachedNetworkImage( +child: CachedNetworkImage( imageUrl: 'https://github.com/$githubUsername.png', width: 40, height: 40, fit: BoxFit.cover, + cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: 40, height: 40, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d360f196..4e236f0c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; @@ -32,6 +33,22 @@ class _TrackMetadataScreenState extends ConsumerState { Color? _dominantColor; bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + static final RegExp _lrcTimestampPattern = + RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); + static const List _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; String? _normalizeOptionalString(String? value) { if (value == null) return null; @@ -64,17 +81,23 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _extractDominantColor() async { - if (widget.item.coverUrl == null) return; + final coverUrl = widget.item.coverUrl; + if (coverUrl == null || coverUrl.isEmpty) return; + if (!coverUrl.startsWith('http://') && !coverUrl.startsWith('https://')) { + return; + } try { final paletteGenerator = await PaletteGenerator.fromImageProvider( - CachedNetworkImageProvider(widget.item.coverUrl!), - maximumColorCount: 16, + CachedNetworkImageProvider(coverUrl), + size: const Size(128, 128), + maximumColorCount: 12, ); - if (mounted) { + final nextColor = paletteGenerator.dominantColor?.color ?? + paletteGenerator.vibrantColor?.color ?? + paletteGenerator.mutedColor?.color; + if (mounted && nextColor != _dominantColor) { setState(() { - _dominantColor = paletteGenerator.dominantColor?.color ?? - paletteGenerator.vibrantColor?.color ?? - paletteGenerator.mutedColor?.color; + _dominantColor = nextColor; }); } } catch (_) { @@ -87,26 +110,26 @@ class _TrackMetadataScreenState extends ConsumerState { if (filePath.startsWith('EXISTS:')) { filePath = filePath.substring(7); } - - final file = File(filePath); - final exists = await file.exists(); + + bool exists = false; int? size; - - if (exists) { - try { - size = await file.length(); - } catch (_) {} - } - - if (mounted) { + try { + final stat = await FileStat.stat(filePath); + exists = stat.type != FileSystemEntityType.notFound; + if (exists) { + size = stat.size; + } + } catch (_) {} + + if (mounted && (exists != _fileExists || size != _fileSize)) { setState(() { _fileExists = exists; _fileSize = size; }); - - if (exists) { - _fetchLyrics(); - } + } + + if (mounted && exists && _lyrics == null && !_lyricsLoading) { + _fetchLyrics(); } } @@ -282,10 +305,11 @@ class _TrackMetadataScreenState extends ConsumerState { child: ClipRRect( borderRadius: BorderRadius.circular(20), child: item.coverUrl != null - ? CachedNetworkImage( +? CachedNetworkImage( imageUrl: item.coverUrl!, fit: BoxFit.cover, memCacheWidth: (coverSize * 2).toInt(), + cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container( color: colorScheme.surfaceContainerHighest, child: Icon( @@ -909,10 +933,9 @@ class _TrackMetadataScreenState extends ConsumerState { String _cleanLrcForDisplay(String lrc) { final lines = lrc.split('\n'); final cleanLines = []; - final timestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); for (final line in lines) { - final cleanLine = line.replaceAll(timestampPattern, '').trim(); + final cleanLine = line.replaceAll(_lrcTimestampPattern, '').trim(); if (cleanLine.isNotEmpty) { cleanLines.add(cleanLine); } @@ -1093,9 +1116,7 @@ class _TrackMetadataScreenState extends ConsumerState { } String _formatFullDate(DateTime date) { - final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${date.day} ${months[date.month - 1]} ${date.year}, ' + return '${date.day} ${_months[date.month - 1]} ${date.year}, ' '${date.hour.toString().padLeft(2, '0')}:' '${date.minute.toString().padLeft(2, '0')}'; } diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart new file mode 100644 index 00000000..d508d251 --- /dev/null +++ b/lib/services/cover_cache_manager.dart @@ -0,0 +1,114 @@ +import 'dart:io'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +/// Persistent cache manager for album/track cover images. +/// +/// Unlike the default cache manager which stores in temp directory +/// (can be cleared by system anytime), this stores in app support +/// directory which persists across app restarts. +class CoverCacheManager { + static const String _cacheKey = 'coverImageCache'; + static const int _maxCacheObjects = 1000; + static const Duration _maxCacheAge = Duration(days: 365); + + static CacheManager? _instance; + static bool _initialized = false; + + /// Get the singleton cache manager instance. + /// Must call [initialize] before using this. + static CacheManager get instance { + if (!_initialized || _instance == null) { + throw StateError( + 'CoverCacheManager not initialized. Call CoverCacheManager.initialize() first.', + ); + } + return _instance!; + } + + /// Check if cache manager is initialized + static bool get isInitialized => _initialized && _instance != null; + + /// Initialize the cache manager with persistent storage path. + /// Call this once during app startup (in main.dart). + static Future initialize() async { + if (_initialized) return; + + final appDir = await getApplicationSupportDirectory(); + final cachePath = p.join(appDir.path, 'cover_cache'); + + // Ensure cache directory exists + await Directory(cachePath).create(recursive: true); + + _instance = CacheManager( + Config( + _cacheKey, + stalePeriod: _maxCacheAge, + maxNrOfCacheObjects: _maxCacheObjects, + repo: JsonCacheInfoRepository(databaseName: _cacheKey), + fileSystem: IOFileSystem(cachePath), + fileService: HttpFileService(), + ), + ); + + _initialized = true; + } + + /// Clear all cached cover images. + /// Returns the number of files deleted. + static Future clearCache() async { + if (!_initialized || _instance == null) return; + await _instance!.emptyCache(); + } + + /// Get cache statistics + static Future getStats() async { + if (!_initialized) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + + final appDir = await getApplicationSupportDirectory(); + final cacheDir = Directory(p.join(appDir.path, 'cover_cache')); + + if (!await cacheDir.exists()) { + return const CacheStats(fileCount: 0, totalSizeBytes: 0); + } + + int fileCount = 0; + int totalSize = 0; + + await for (final entity in cacheDir.list(recursive: true)) { + if (entity is File) { + fileCount++; + totalSize += await entity.length(); + } + } + + return CacheStats(fileCount: fileCount, totalSizeBytes: totalSize); + } +} + +/// Statistics about the cover image cache +class CacheStats { + final int fileCount; + final int totalSizeBytes; + + const CacheStats({ + required this.fileCount, + required this.totalSizeBytes, + }); + + /// Get human-readable size string + String get formattedSize { + if (totalSizeBytes < 1024) { + return '$totalSizeBytes B'; + } else if (totalSizeBytes < 1024 * 1024) { + return '${(totalSizeBytes / 1024).toStringAsFixed(1)} KB'; + } else if (totalSizeBytes < 1024 * 1024 * 1024) { + return '${(totalSizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } else { + return '${(totalSizeBytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + } +} diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart new file mode 100644 index 00000000..6c01e415 --- /dev/null +++ b/lib/widgets/cached_cover_image.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; + +/// A wrapper around CachedNetworkImage that uses persistent cache storage. +/// +/// This ensures cover images are cached to disk and persist across app restarts, +/// instead of being stored in the temporary directory that can be cleared by the OS. +class CachedCoverImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final int? memCacheWidth; + final int? memCacheHeight; + final Widget Function(BuildContext, String, Object)? errorWidget; + final Widget Function(BuildContext, String)? placeholder; + final BorderRadius? borderRadius; + + const CachedCoverImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.memCacheWidth, + this.memCacheHeight, + this.errorWidget, + this.placeholder, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + final image = CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance + : null, + errorWidget: errorWidget, + placeholder: placeholder, + ); + + if (borderRadius != null) { + return ClipRRect( + borderRadius: borderRadius!, + child: image, + ); + } + + return image; + } +} + +/// Provider for CachedNetworkImageProvider that uses persistent cache. +/// Use this for precacheImage() calls. +CachedNetworkImageProvider cachedCoverImageProvider(String url) { + return CachedNetworkImageProvider( + url, + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance + : null, + ); +} diff --git a/pubspec.lock b/pubspec.lock index a2b61026..5233bceb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -327,7 +327,7 @@ packages: source: sdk version: "0.0.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" @@ -662,7 +662,7 @@ packages: source: hosted version: "0.3.3+7" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index 9825b60a..ced54854 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,9 +22,10 @@ dependencies: # Navigation go_router: ^17.0.1 - # Storage & Persistence +# Storage & Persistence shared_preferences: ^2.5.3 path_provider: ^2.1.5 + path: ^1.9.0 # HTTP & Network http: ^1.6.0 @@ -33,6 +34,7 @@ dependencies: # UI Components cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index da99f5d8..94fd2e1b 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -22,17 +22,19 @@ dependencies: # Navigation go_router: ^17.0.1 - # Storage & Persistence +# Storage & Persistence shared_preferences: ^2.5.3 path_provider: ^2.1.5 + path: ^1.9.0 # HTTP & Network http: ^1.6.0 dio: ^5.8.0 - # UI Components +# UI Components cupertino_icons: ^1.0.8 cached_network_image: ^3.4.1 + flutter_cache_manager: ^3.4.1 flutter_svg: ^2.1.0 # Material Expressive 3 / Dynamic Color