mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-21 15:36:50 +02:00
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:
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user