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
This commit is contained in:
zarzet
2026-01-19 22:55:53 +07:00
parent 0119db094d
commit 77e4457244
21 changed files with 650 additions and 221 deletions
+39
View File
@@ -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
+21 -3
View File
@@ -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
+6 -4
View File
@@ -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(
+136 -46
View File
@@ -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<DownloadQueueState> {
Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup
@@ -383,6 +395,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _completedInSession = 0; // Track completed downloads in current session
int _failedInSession = 0; // Track failed downloads in current session
bool _isLoaded = false;
final Set<String> _ensuredDirs = {};
@override
DownloadQueueState build() {
@@ -475,6 +488,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
final currentItems = state.items;
final itemsById = <String, DownloadItem>{};
final itemIndexById = <String, int>{};
for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i];
itemsById[item.id] = item;
itemIndexById[item.id] = i;
}
final progressUpdates = <String, _ProgressUpdate>{};
bool hasFinalizingItem = false;
String? finalizingTrackName;
@@ -482,9 +504,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
}
}
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<DownloadItem>.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<DownloadQueueState> {
}
}
Future<void> _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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadItem>.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<DownloadQueueState> {
}
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, DownloadQueueState>(
DownloadQueueNotifier.new,
);
class DownloadQueueLookup {
final Map<String, DownloadItem> byTrackId;
DownloadQueueLookup._(this.byTrackId);
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
final map = <String, DownloadItem>{};
for (final item in items) {
map.putIfAbsent(item.track.id, () => item);
}
return DownloadQueueLookup._(map);
}
}
final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
return DownloadQueueLookup.fromItems(items);
});
+3
View File
@@ -488,6 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
/// Set search text state for back button handling
void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) {
return;
}
state = state.copyWith(hasSearchText: hasText);
}
+8 -6
View File
@@ -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<AlbumScreen> {
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)),
+10 -6
View File
@@ -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,
+31 -24
View File
@@ -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<DownloadedAlbumScreen> {
});
}
/// Get unique disc numbers from tracks (sorted)
List<int> _getDiscNumbers(List<DownloadHistoryItem> tracks) {
final discNumbers = tracks
.map((t) => t.discNumber ?? 1)
.toSet()
.toList()
..sort();
return discNumbers;
}
/// Check if album has multiple discs
bool _hasMultipleDiscs(List<DownloadHistoryItem> tracks) {
return _getDiscNumbers(tracks).length > 1;
}
/// Get tracks for a specific disc
List<DownloadHistoryItem> _getTracksForDisc(List<DownloadHistoryItem> tracks, int discNumber) {
return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList();
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
List<DownloadHistoryItem> tracks,
) {
final discMap = <int, List<DownloadHistoryItem>>{};
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<DownloadedAlbumScreen> {
}
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<DownloadedAlbumScreen> {
));
}
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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> 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<DownloadedAlbumScreen> {
}
// Multiple discs - build list with separators
final discNumbers = _getDiscNumbers(tracks);
final discNumbers = discMap.keys.toList()..sort();
final List<Widget> 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));
+5 -2
View File
@@ -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<HomeScreen> {
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<HomeScreen> {
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(
+50 -14
View File
@@ -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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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<HomeTab> 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 = <Track>[];
final realTrackIndexes = <int>[];
final albumItems = <Track>[];
final playlistItems = <Track>[];
final artistItems = <Track>[];
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<HomeTab> 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<HomeTab> 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,
+8 -6
View File
@@ -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<PlaylistScreen> {
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)),
+3 -1
View File
@@ -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(
+83 -74
View File
@@ -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<String, int> 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<QueueTab> {
}
}
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<QueueTab> {
),
);
_precacheCover(historyItem.coverUrl);
Navigator.push(
context,
PageRouteBuilder(
@@ -266,6 +293,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
Navigator.push(
context,
PageRouteBuilder(
@@ -285,15 +313,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
List<DownloadHistoryItem> _filterHistoryItems(
List<DownloadHistoryItem> items,
String filterMode,
Map<String, int> albumCounts,
) {
if (filterMode == 'all') return items;
final albumCounts = <String, int>{};
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<QueueTab> {
}
}
/// Count albums vs singles for filter chips
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
_HistoryStats _buildHistoryStats(List<DownloadHistoryItem> items) {
final albumCounts = <String, int>{};
final albumMap = <String, List<DownloadHistoryItem>>{};
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<DownloadHistoryItem> items) {
final albumMap = <String, List<DownloadHistoryItem>>{};
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<DownloadHistoryItem> items) {
final albumKeys = <String>{};
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<QueueTab> {
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<QueueTab> {
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
albumCounts: historyStats.albumCounts,
),
_buildFilterContent(
context: context,
@@ -688,6 +685,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
albumCounts: historyStats.albumCounts,
),
_buildFilterContent(
context: context,
@@ -697,6 +695,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
historyViewMode: historyViewMode,
queueItems: queueItems,
groupedAlbums: groupedAlbums,
albumCounts: historyStats.albumCounts,
),
],
),
@@ -713,7 +712,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
child: _buildSelectionBottomBar(
context,
colorScheme,
_filterHistoryItems(allHistoryItems, historyFilterMode),
_filterHistoryItems(
allHistoryItems,
historyFilterMode,
historyStats.albumCounts,
),
bottomPadding,
),
),
@@ -731,8 +734,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required String historyViewMode,
required List<DownloadItem> queueItems,
required List<_GroupedAlbum> groupedAlbums,
required Map<String, int> albumCounts,
}) {
final historyItems = _filterHistoryItems(allHistoryItems, filterMode);
final historyItems =
_filterHistoryItems(allHistoryItems, filterMode, albumCounts);
return CustomScrollView(
slivers: [
@@ -943,13 +948,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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(
+3 -1
View File
@@ -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<SearchScreen> {
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(
+3 -1
View File
@@ -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,
+49 -28
View File
@@ -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<TrackMetadataScreen> {
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<String> _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<TrackMetadataScreen> {
}
Future<void> _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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n');
final cleanLines = <String>[];
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<TrackMetadataScreen> {
}
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')}';
}
+114
View File
@@ -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<void> 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<void> clearCache() async {
if (!_initialized || _instance == null) return;
await _instance!.emptyCache();
}
/// Get cache statistics
static Future<CacheStats> 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';
}
}
}
+69
View File
@@ -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,
);
}
+2 -2
View File
@@ -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"
+3 -1
View File
@@ -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
+4 -2
View File
@@ -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