diff --git a/go_backend/deezer.go b/go_backend/deezer.go index dfddb890..09254fc2 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -28,15 +28,23 @@ const ( deezerAPITimeoutMobile = 25 * time.Second deezerMaxRetries = 2 deezerRetryDelay = 500 * time.Millisecond + + deezerMaxSearchCacheEntries = 300 + deezerMaxAlbumCacheEntries = 200 + deezerMaxArtistCacheEntries = 200 + deezerMaxISRCCacheEntries = 4000 + deezerCacheCleanupInterval = 5 * time.Minute ) type DeezerClient struct { - httpClient *http.Client - searchCache map[string]*cacheEntry - albumCache map[string]*cacheEntry - artistCache map[string]*cacheEntry - isrcCache map[string]string - cacheMu sync.RWMutex + httpClient *http.Client + searchCache map[string]*cacheEntry + albumCache map[string]*cacheEntry + artistCache map[string]*cacheEntry + isrcCache map[string]string + cacheMu sync.RWMutex + lastCacheCleanup time.Time + cacheCleanupInterval time.Duration } var ( @@ -47,16 +55,111 @@ var ( func GetDeezerClient() *DeezerClient { deezerClientOnce.Do(func() { deezerClient = &DeezerClient{ - httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile), - searchCache: make(map[string]*cacheEntry), - albumCache: make(map[string]*cacheEntry), - artistCache: make(map[string]*cacheEntry), - isrcCache: make(map[string]string), + httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile), + searchCache: make(map[string]*cacheEntry), + albumCache: make(map[string]*cacheEntry), + artistCache: make(map[string]*cacheEntry), + isrcCache: make(map[string]string), + cacheCleanupInterval: deezerCacheCleanupInterval, } }) return deezerClient } +func (c *DeezerClient) pruneExpiredCacheEntriesLocked( + cache map[string]*cacheEntry, + now time.Time, +) { + for key, entry := range cache { + if entry == nil || now.After(entry.expiresAt) { + delete(cache, key) + } + } +} + +func (c *DeezerClient) trimCacheEntriesLocked( + cache map[string]*cacheEntry, + maxEntries int, +) { + if maxEntries <= 0 || len(cache) <= maxEntries { + return + } + + for len(cache) > maxEntries { + var oldestKey string + var oldestExpiry time.Time + first := true + for key, entry := range cache { + expiry := time.Time{} + if entry != nil { + expiry = entry.expiresAt + } + if first || expiry.Before(oldestExpiry) { + first = false + oldestKey = key + oldestExpiry = expiry + } + } + if oldestKey == "" { + return + } + delete(cache, oldestKey) + } +} + +func (c *DeezerClient) trimStringCacheEntriesLocked( + cache map[string]string, + maxEntries int, +) { + if maxEntries <= 0 || len(cache) <= maxEntries { + return + } + + toRemove := len(cache) - maxEntries + for key := range cache { + delete(cache, key) + toRemove-- + if toRemove <= 0 { + return + } + } +} + +func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) { + periodicCleanupDue := c.cacheCleanupInterval > 0 && + (c.lastCacheCleanup.IsZero() || + now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval) + + if periodicCleanupDue { + c.pruneExpiredCacheEntriesLocked(c.searchCache, now) + c.pruneExpiredCacheEntriesLocked(c.albumCache, now) + c.pruneExpiredCacheEntriesLocked(c.artistCache, now) + c.lastCacheCleanup = now + } + + if len(c.searchCache) > deezerMaxSearchCacheEntries { + if !periodicCleanupDue { + c.pruneExpiredCacheEntriesLocked(c.searchCache, now) + } + c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries) + } + if len(c.albumCache) > deezerMaxAlbumCacheEntries { + if !periodicCleanupDue { + c.pruneExpiredCacheEntriesLocked(c.albumCache, now) + } + c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries) + } + if len(c.artistCache) > deezerMaxArtistCacheEntries { + if !periodicCleanupDue { + c.pruneExpiredCacheEntriesLocked(c.artistCache, now) + } + c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries) + } + if len(c.isrcCache) > deezerMaxISRCCacheEntries { + c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries) + } +} + type deezerTrack struct { ID int64 `json:"id"` Title string `json:"title"` @@ -414,10 +517,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists)) c.cacheMu.Lock() + now := time.Now() c.searchCache[cacheKey] = &cacheEntry{ data: result, - expiresAt: time.Now().Add(deezerCacheTTL), + expiresAt: now.Add(deezerCacheTTL), } + c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() return result, nil @@ -555,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp } c.cacheMu.Lock() + now := time.Now() c.albumCache[albumID] = &cacheEntry{ data: result, - expiresAt: time.Now().Add(deezerCacheTTL), + expiresAt: now.Add(deezerCacheTTL), } + c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() return result, nil @@ -638,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR } c.cacheMu.Lock() + now := time.Now() c.artistCache[artistID] = &cacheEntry{ data: result, - expiresAt: time.Now().Add(deezerCacheTTL), + expiresAt: now.Add(deezerCacheTTL), } + c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() return result, nil @@ -807,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr for trackIDStr, isrc := range directISRCs { c.isrcCache[trackIDStr] = isrc } + c.maybeCleanupCachesLocked(time.Now()) c.cacheMu.Unlock() } @@ -841,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr c.cacheMu.Lock() c.isrcCache[trackIDStr] = fullTrack.ISRC + c.maybeCleanupCachesLocked(time.Now()) c.cacheMu.Unlock() }(track) } @@ -864,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string c.cacheMu.Lock() c.isrcCache[trackID] = fullTrack.ISRC + c.maybeCleanupCachesLocked(time.Now()) c.cacheMu.Unlock() return fullTrack.ISRC, nil @@ -946,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str } c.cacheMu.Lock() + now := time.Now() c.searchCache[cacheKey] = &cacheEntry{ data: result, - expiresAt: time.Now().Add(deezerCacheTTL), + expiresAt: now.Add(deezerCacheTTL), } + c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label) diff --git a/lib/main.dart b/lib/main.dart index c2226f63..2e19a7a8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,12 +11,21 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + _configureImageCache(); runApp( ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())), ); } +void _configureImageCache() { + final imageCache = PaintingBinding.instance.imageCache; + // Keep memory cache bounded so cover-heavy pages don't retain too many + // full-resolution images simultaneously. + imageCache.maximumSize = 240; + imageCache.maximumSizeBytes = 60 << 20; // 60 MiB +} + /// Widget to eagerly initialize providers that need to load data on startup class _EagerInitialization extends ConsumerStatefulWidget { const _EagerInitialization({required this.child}); diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4cce155a..9a74729c 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -673,6 +673,7 @@ class DownloadQueueNotifier extends Notifier { static const _queueStorageKey = 'download_queue'; static const _progressPollingInterval = Duration(milliseconds: 800); static const _queueSchedulingInterval = Duration(milliseconds: 250); + static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. final NotificationService _notificationService = NotificationService(); final Future _prefs = SharedPreferences.getInstance(); int _totalQueuedAtStart = 0; @@ -686,6 +687,55 @@ class DownloadQueueNotifier extends Notifier { int _lastServicePercent = -1; int _lastServiceQueueCount = -1; DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0); + String? _lastFinalizingTrackName; + String? _lastFinalizingArtistName; + String? _lastNotifTrackName; + String? _lastNotifArtistName; + int _lastNotifPercent = -1; + int _lastNotifQueueCount = -1; + + double _normalizeProgressForUi(double value) { + final clamped = value.clamp(0.0, 1.0).toDouble(); + if (clamped <= 0) return 0; + if (clamped >= 1) return 1; + final rounded = double.parse(clamped.toStringAsFixed(2)); + return rounded == 0 ? 0.01 : rounded; + } + + double _normalizeSpeedForUi(double value) { + if (value <= 0) return 0; + return double.parse(value.toStringAsFixed(1)); + } + + int _normalizeBytesForUi(int value) { + if (value <= 0) return 0; + return (value ~/ _bytesUiStep) * _bytesUiStep; + } + + bool _shouldUpdateProgressNotification({ + required String trackName, + required String artistName, + required int progress, + required int total, + required int queueCount, + }) { + final safeTotal = total > 0 ? total : 1; + final percent = ((progress * 100) / safeTotal).round().clamp(0, 100); + final changed = + trackName != _lastNotifTrackName || + artistName != _lastNotifArtistName || + percent != _lastNotifPercent || + queueCount != _lastNotifQueueCount; + if (!changed) { + return false; + } + + _lastNotifTrackName = trackName; + _lastNotifArtistName = artistName; + _lastNotifPercent = percent; + _lastNotifQueueCount = queueCount; + return true; + } @override DownloadQueueState build() { @@ -854,12 +904,15 @@ class DownloadQueueNotifier extends Notifier { } else { percentage = progressFromBackend; } + final normalizedProgress = _normalizeProgressForUi(percentage); + final normalizedSpeed = _normalizeSpeedForUi(speedMBps); + final normalizedBytes = _normalizeBytesForUi(bytesReceived); progressUpdates[itemId] = _ProgressUpdate( status: DownloadStatus.downloading, - progress: percentage, - speedMBps: speedMBps, - bytesReceived: bytesReceived, + progress: normalizedProgress, + speedMBps: normalizedSpeed, + bytesReceived: normalizedBytes, ); final mbReceived = bytesReceived / (1024 * 1024); @@ -914,12 +967,20 @@ class DownloadQueueNotifier extends Notifier { } if (hasFinalizingItem && finalizingTrackName != null) { - _notificationService.showDownloadFinalizing( - trackName: finalizingTrackName, - artistName: finalizingArtistName ?? '', - ); + final safeArtistName = finalizingArtistName ?? ''; + if (finalizingTrackName != _lastFinalizingTrackName || + safeArtistName != _lastFinalizingArtistName) { + _notificationService.showDownloadFinalizing( + trackName: finalizingTrackName, + artistName: safeArtistName, + ); + _lastFinalizingTrackName = finalizingTrackName; + _lastFinalizingArtistName = safeArtistName; + } return; } + _lastFinalizingTrackName = null; + _lastFinalizingArtistName = null; if (items.isNotEmpty) { final firstEntry = items.entries.first; @@ -945,19 +1006,28 @@ class DownloadQueueNotifier extends Notifier { notifTotal = 100; } - _notificationService.showDownloadProgress( + final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; + if (_shouldUpdateProgressNotification( trackName: trackName, artistName: artistName, progress: notifProgress, - total: notifTotal > 0 ? notifTotal : 1, - ); + total: safeNotifTotal, + queueCount: queuedCount, + )) { + _notificationService.showDownloadProgress( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + ); + } if (Platform.isAndroid) { _maybeUpdateAndroidDownloadService( trackName: firstDownloading.track.name, artistName: firstDownloading.track.artistName, progress: notifProgress, - total: notifTotal > 0 ? notifTotal : 1, + total: safeNotifTotal, queueCount: queuedCount, ); } @@ -1023,6 +1093,12 @@ class DownloadQueueNotifier extends Notifier { _lastServicePercent = -1; _lastServiceQueueCount = -1; _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0); + _lastFinalizingTrackName = null; + _lastFinalizingArtistName = null; + _lastNotifTrackName = null; + _lastNotifArtistName = null; + _lastNotifPercent = -1; + _lastNotifQueueCount = -1; } Future _initOutputDir() async { diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index a42f2b17..08764056 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -39,16 +39,23 @@ class LocalLibraryState { this.scanWasCancelled = false, this.lastScannedAt, this.excludedDownloadedCount = 0, - }) : _isrcSet = items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => item.isrc!) - .toSet(), - _trackKeySet = items.map((item) => item.matchKey).toSet(), - _byIsrc = Map.fromEntries( - items - .where((item) => item.isrc != null && item.isrc!.isNotEmpty) - .map((item) => MapEntry(item.isrc!, item)), - ); + Set? isrcSet, + Set? trackKeySet, + Map? byIsrc, + }) : _isrcSet = + isrcSet ?? + items + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => item.isrc!) + .toSet(), + _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(), + _byIsrc = + byIsrc ?? + Map.fromEntries( + items + .where((item) => item.isrc != null && item.isrc!.isNotEmpty) + .map((item) => MapEntry(item.isrc!, item)), + ); bool hasIsrc(String isrc) => _isrcSet.contains(isrc); @@ -86,8 +93,11 @@ class LocalLibraryState { DateTime? lastScannedAt, int? excludedDownloadedCount, }) { + final nextItems = items ?? this.items; + final keepDerivedIndex = identical(nextItems, this.items); + return LocalLibraryState( - items: items ?? this.items, + items: nextItems, isScanning: isScanning ?? this.isScanning, scanProgress: scanProgress ?? this.scanProgress, scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile, @@ -98,6 +108,9 @@ class LocalLibraryState { lastScannedAt: lastScannedAt ?? this.lastScannedAt, excludedDownloadedCount: excludedDownloadedCount ?? this.excludedDownloadedCount, + isrcSet: keepDerivedIndex ? _isrcSet : null, + trackKeySet: keepDerivedIndex ? _trackKeySet : null, + byIsrc: keepDerivedIndex ? _byIsrc : null, ); } } @@ -397,14 +410,33 @@ class LocalLibraryNotifier extends Notifier { _progressTimer = Timer.periodic(_progressPollingInterval, (_) async { try { final progress = await PlatformBridge.getLibraryScanProgress(); - - state = state.copyWith( - scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0, - scanCurrentFile: progress['current_file'] as String?, - scanTotalFiles: progress['total_files'] as int? ?? 0, - scannedFiles: progress['scanned_files'] as int? ?? 0, - scanErrorCount: progress['error_count'] as int? ?? 0, + final nextProgress = + (progress['progress_pct'] as num?)?.toDouble() ?? 0; + final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( + 0.0, + 100.0, ); + final currentFile = progress['current_file'] as String?; + final totalFiles = progress['total_files'] as int? ?? 0; + final scannedFiles = progress['scanned_files'] as int? ?? 0; + final errorCount = progress['error_count'] as int? ?? 0; + + final shouldUpdateState = + state.scanProgress != normalizedProgress || + state.scanCurrentFile != currentFile || + state.scanTotalFiles != totalFiles || + state.scannedFiles != scannedFiles || + state.scanErrorCount != errorCount; + + if (shouldUpdateState) { + state = state.copyWith( + scanProgress: normalizedProgress, + scanCurrentFile: currentFile, + scanTotalFiles: totalFiles, + scannedFiles: scannedFiles, + scanErrorCount: errorCount, + ); + } if (progress['is_complete'] == true) { _stopProgressPolling(); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 6e2229ac..8224c2b5 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -268,6 +268,13 @@ class _AlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final dpr = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 3.0).toDouble(); + final backgroundMemCacheWidth = (constraints.maxWidth * dpr) + .round() + .clamp(720, 1440) + .toInt(); return FlexibleSpaceBar( collapseMode: CollapseMode.none, @@ -279,6 +286,7 @@ class _AlbumScreenState extends ConsumerState { CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, + memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 28ff7e2f..d6950a9e 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; /// Screen to display downloaded tracks from a specific album class DownloadedAlbumScreen extends ConsumerStatefulWidget { @@ -191,10 +193,21 @@ class _DownloadedAlbumScreenState extends ConsumerState { } } - void _navigateToMetadataScreen(DownloadHistoryItem item) { + void _onEmbeddedCoverChanged() { + if (!mounted) return; + setState(() {}); + } + + Future _navigateToMetadataScreen(DownloadHistoryItem item) async { + final navigator = Navigator.of(context); _precacheCover(item.coverUrl); - Navigator.push( - context, + final beforeModTime = + await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( + item.filePath, + ); + if (!mounted) return; + + final result = await navigator.push( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -204,6 +217,12 @@ class _DownloadedAlbumScreenState extends ConsumerState { FadeTransition(opacity: animation, child: child), ), ); + await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( + item.filePath, + beforeModTime: beforeModTime, + force: result == true, + onChanged: _onEmbeddedCoverChanged, + ); } void _precacheCover(String? url) { @@ -211,8 +230,19 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (!url.startsWith('http://') && !url.startsWith('https://')) { return; } + final dpr = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 3.0).toDouble(); + final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); precacheImage( - CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + ResizeImage( + CachedNetworkImageProvider( + url, + cacheManager: CoverCacheManager.instance, + ), + width: targetSize, + height: targetSize, + ), context, ); } @@ -256,7 +286,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { CustomScrollView( controller: _scrollController, slivers: [ - _buildAppBar(context, colorScheme), + _buildAppBar(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks), _buildTrackListHeader(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks), @@ -285,7 +315,19 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } - Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { + String? _resolveAlbumEmbeddedCoverPath(List tracks) { + if (tracks.isEmpty) return null; + return DownloadedEmbeddedCoverResolver.resolve( + tracks.first.filePath, + onChanged: _onEmbeddedCoverChanged, + ); + } + + Widget _buildAppBar( + BuildContext context, + ColorScheme colorScheme, + List tracks, + ) { final mediaSize = MediaQuery.of(context).size; final screenWidth = mediaSize.width; final shortestSide = mediaSize.shortestSide; @@ -294,6 +336,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0); final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0); final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0); + final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks); return SliverAppBar( expandedHeight: expandedHeight, @@ -322,6 +365,13 @@ class _DownloadedAlbumScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final dpr = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 3.0).toDouble(); + final backgroundMemCacheWidth = (constraints.maxWidth * dpr) + .round() + .clamp(720, 1440) + .toInt(); return FlexibleSpaceBar( collapseMode: CollapseMode.none, @@ -329,10 +379,19 @@ class _DownloadedAlbumScreenState extends ConsumerState { fit: StackFit.expand, children: [ // Blurred cover background - if (widget.coverUrl != null) + if (embeddedCoverPath != null) + Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + cacheWidth: backgroundMemCacheWidth, + errorBuilder: (_, _, _) => + Container(color: colorScheme.surface), + ) + else if (widget.coverUrl != null) CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, + memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), @@ -389,7 +448,22 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), child: ClipRRect( borderRadius: BorderRadius.circular(20), - child: widget.coverUrl != null + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + cacheWidth: (coverSize * 2).toInt(), + cacheHeight: (coverSize * 2).toInt(), + errorBuilder: (_, _, _) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + size: fallbackIconSize, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : widget.coverUrl != null ? CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 35207f2e..aa5c8d79 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -18,6 +18,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; +import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -35,11 +36,13 @@ class HomeTab extends ConsumerStatefulWidget { class _RecentAccessView { final List uniqueItems; final List downloadItems; + final Map downloadFilePathByRecentKey; final bool hasHiddenDownloads; const _RecentAccessView({ required this.uniqueItems, required this.downloadItems, + required this.downloadFilePathByRecentKey, required this.hasHiddenDownloads, }); } @@ -932,7 +935,9 @@ class _HomeTabState extends ConsumerState ), // Search filter bar (only shown when has search results) - if (searchFilters.isNotEmpty && hasActualResults && !showRecentAccess) + if (searchFilters.isNotEmpty && + hasActualResults && + !showRecentAccess) SliverToBoxAdapter( child: _buildSearchFilterBar( searchFilters, @@ -1022,6 +1027,11 @@ class _HomeTabState extends ConsumerState ); } + void _onEmbeddedCoverChanged() { + if (!mounted) return; + setState(() {}); + } + Widget _buildRecentDownloads( List items, ColorScheme colorScheme, @@ -1049,6 +1059,10 @@ class _HomeTabState extends ConsumerState itemCount: itemCount, itemBuilder: (context, index) { final item = items[index]; + final embeddedCoverPath = DownloadedEmbeddedCoverResolver.resolve( + item.filePath, + onChanged: _onEmbeddedCoverChanged, + ); return KeyedSubtree( key: ValueKey(item.id), child: GestureDetector( @@ -1060,7 +1074,26 @@ class _HomeTabState extends ConsumerState children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: item.coverUrl != null + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + width: coverSize, + height: coverSize, + fit: BoxFit.cover, + cacheWidth: (coverSize * 2).round(), + cacheHeight: (coverSize * 2).round(), + errorBuilder: (_, _, _) => Container( + width: coverSize, + height: coverSize, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), + ), + ) + : item.coverUrl != null ? CachedNetworkImage( imageUrl: item.coverUrl!, width: coverSize, @@ -1125,6 +1158,7 @@ class _HomeTabState extends ConsumerState } final downloadItems = []; + final downloadFilePathByRecentKey = {}; for (final entry in albumGroups.entries) { final tracks = entry.value; final mostRecent = tracks.reduce( @@ -1136,29 +1170,31 @@ class _HomeTabState extends ConsumerState : mostRecent.artistName; if (tracks.length == 1) { - downloadItems.add( - RecentAccessItem( - id: mostRecent.spotifyId ?? mostRecent.id, - name: mostRecent.trackName, - subtitle: mostRecent.artistName, - imageUrl: mostRecent.coverUrl, - type: RecentAccessType.track, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ), + final recent = RecentAccessItem( + id: mostRecent.spotifyId ?? mostRecent.id, + name: mostRecent.trackName, + subtitle: mostRecent.artistName, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.track, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', ); + downloadItems.add(recent); + downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = + mostRecent.filePath; } else { - downloadItems.add( - RecentAccessItem( - id: '${mostRecent.albumName}|$artistForKey', - name: mostRecent.albumName, - subtitle: artistForKey, - imageUrl: mostRecent.coverUrl, - type: RecentAccessType.album, - accessedAt: mostRecent.downloadedAt, - providerId: 'download', - ), + final recent = RecentAccessItem( + id: '${mostRecent.albumName}|$artistForKey', + name: mostRecent.albumName, + subtitle: artistForKey, + imageUrl: mostRecent.coverUrl, + type: RecentAccessType.album, + accessedAt: mostRecent.downloadedAt, + providerId: 'download', ); + downloadItems.add(recent); + downloadFilePathByRecentKey['${recent.type.name}:${recent.id}'] = + mostRecent.filePath; } } @@ -1192,6 +1228,7 @@ class _HomeTabState extends ConsumerState final view = _RecentAccessView( uniqueItems: uniqueItems, downloadItems: downloadItems, + downloadFilePathByRecentKey: downloadFilePathByRecentKey, hasHiddenDownloads: hiddenIds.isNotEmpty, ); @@ -1680,7 +1717,11 @@ class _HomeTabState extends ConsumerState ) else ...uniqueItems.map( - (item) => _buildRecentAccessItem(item, colorScheme), + (item) => _buildRecentAccessItem( + item, + colorScheme, + view.downloadFilePathByRecentKey, + ), ), ], ), @@ -1690,10 +1731,17 @@ class _HomeTabState extends ConsumerState Widget _buildRecentAccessItem( RecentAccessItem item, ColorScheme colorScheme, + Map downloadFilePathByRecentKey, ) { IconData typeIcon; String typeLabel; final isDownloaded = item.providerId == 'download'; + final embeddedCoverPath = isDownloaded + ? DownloadedEmbeddedCoverResolver.resolve( + downloadFilePathByRecentKey['${item.type.name}:${item.id}'], + onChanged: _onEmbeddedCoverChanged, + ) + : null; switch (item.type) { case RecentAccessType.artist: @@ -1723,7 +1771,25 @@ class _HomeTabState extends ConsumerState borderRadius: BorderRadius.circular( item.type == RecentAccessType.artist ? 28 : 4, ), - child: item.imageUrl != null && item.imageUrl!.isNotEmpty + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + width: 56, + height: 56, + fit: BoxFit.cover, + cacheWidth: 112, + cacheHeight: 112, + errorBuilder: (context, error, stackTrace) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon( + typeIcon, + color: colorScheme.onSurfaceVariant, + ), + ), + ) + : item.imageUrl != null && item.imageUrl!.isNotEmpty ? CachedNetworkImage( imageUrl: item.imageUrl!, width: 56, @@ -1896,10 +1962,15 @@ class _HomeTabState extends ConsumerState } } - void _navigateToMetadataScreen(DownloadHistoryItem item) { + Future _navigateToMetadataScreen(DownloadHistoryItem item) async { + final navigator = Navigator.of(context); _precacheCover(item.coverUrl); - Navigator.push( - context, + final beforeModTime = + await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( + item.filePath, + ); + if (!mounted) return; + final result = await navigator.push( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -1909,6 +1980,12 @@ class _HomeTabState extends ConsumerState FadeTransition(opacity: animation, child: child), ), ); + await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( + item.filePath, + beforeModTime: beforeModTime, + force: result == true, + onChanged: _onEmbeddedCoverChanged, + ); } void _precacheCover(String? url) { @@ -1916,8 +1993,19 @@ class _HomeTabState extends ConsumerState if (!url.startsWith('http://') && !url.startsWith('https://')) { return; } + final dpr = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 3.0).toDouble(); + final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); precacheImage( - CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + ResizeImage( + CachedNetworkImageProvider( + url, + cacheManager: CoverCacheManager.instance, + ), + width: targetSize, + height: targetSize, + ), context, ); } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 933fdd2c..ef7d488b 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -181,6 +181,13 @@ class _PlaylistScreenState extends ConsumerState { (constraints.maxHeight - kToolbarHeight) / (expandedHeight - kToolbarHeight); final showContent = collapseRatio > 0.3; + final dpr = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 3.0).toDouble(); + final backgroundMemCacheWidth = (constraints.maxWidth * dpr) + .round() + .clamp(720, 1440) + .toInt(); return FlexibleSpaceBar( collapseMode: CollapseMode.none, @@ -192,6 +199,7 @@ class _PlaylistScreenState extends ConsumerState { CachedNetworkImage( imageUrl: widget.coverUrl!, fit: BoxFit.cover, + memCacheWidth: backgroundMemCacheWidth, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 95c886dc..25f4e9a9 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -210,6 +210,20 @@ class _UnifiedCacheEntry { }); } +class _QueueItemIdsSnapshot { + final List ids; + + const _QueueItemIdsSnapshot(this.ids); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _QueueItemIdsSnapshot && listEquals(ids, other.ids); + + @override + int get hashCode => Object.hashAll(ids); +} + Map> _filterHistoryInIsolate(Map payload) { final entries = (payload['entries'] as List).cast(); final albumCounts = (payload['albumCounts'] as Map).cast(); @@ -722,10 +736,11 @@ class _QueueTabState extends ConsumerState { if (confirmed == true && mounted) { final historyNotifier = ref.read(downloadHistoryProvider.notifier); final localLibraryDb = LibraryDatabase.instance; + final itemsById = {for (final item in allItems) item.id: item}; int deletedCount = 0; for (final id in _selectedIds) { - final item = allItems.where((e) => e.id == id).firstOrNull; + final item = itemsById[id]; if (item != null) { try { final cleanPath = _cleanFilePath(item.filePath); @@ -811,7 +826,8 @@ class _QueueTabState extends ConsumerState { } try { - return File(cleanPath).statSync().modified.millisecondsSinceEpoch; + final stat = await File(cleanPath).stat(); + return stat.modified.millisecondsSinceEpoch; } catch (_) { return null; } @@ -987,6 +1003,21 @@ class _QueueTabState extends ConsumerState { }); } + String _fileExtLower(String filePath) { + final dotIndex = filePath.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == filePath.length - 1) { + return ''; + } + return filePath.substring(dotIndex + 1).toLowerCase(); + } + + String? _localQualityLabel(LocalLibraryItem item) { + if (item.bitDepth == null || item.sampleRate == null) { + return null; + } + return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; + } + List _applyAdvancedFilters( List items, ) { @@ -1024,7 +1055,7 @@ class _QueueTabState extends ConsumerState { } if (_filterFormat != null) { - final ext = item.filePath.split('.').last.toLowerCase(); + final ext = _fileExtLower(item.filePath); if (ext != _filterFormat) return false; } @@ -1080,7 +1111,7 @@ class _QueueTabState extends ConsumerState { /// Check if a file path passes the current format filter bool _passesFormatFilter(String filePath) { if (_filterFormat == null) return true; - return filePath.split('.').last.toLowerCase() == _filterFormat; + return _fileExtLower(filePath) == _filterFormat; } /// Filter grouped download albums by search query + advanced filters @@ -1105,15 +1136,15 @@ class _QueueTabState extends ConsumerState { // Filter tracks within the album by advanced filters if (_filterQuality != null || _filterFormat != null) { - final filteredTracks = album.tracks - .where((track) { - if (!_passesQualityFilter(track.quality)) return false; - if (!_passesFormatFilter(track.filePath)) return false; - return true; - }) - .toList(growable: false); + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_passesQualityFilter(track.quality)) continue; + if (!_passesFormatFilter(track.filePath)) continue; + hasMatchingTrack = true; + break; + } - if (filteredTracks.isEmpty) continue; + if (!hasMatchingTrack) continue; } result.add(album); @@ -1162,20 +1193,15 @@ class _QueueTabState extends ConsumerState { // Filter tracks within the album by advanced filters if (_filterQuality != null || _filterFormat != null) { - final filteredTracks = album.tracks - .where((track) { - String? quality; - if (track.bitDepth != null && track.sampleRate != null) { - quality = - '${track.bitDepth}bit/${(track.sampleRate! / 1000).toStringAsFixed(1)}kHz'; - } - if (!_passesQualityFilter(quality)) return false; - if (!_passesFormatFilter(track.filePath)) return false; - return true; - }) - .toList(growable: false); + var hasMatchingTrack = false; + for (final track in album.tracks) { + if (!_passesQualityFilter(_localQualityLabel(track))) continue; + if (!_passesFormatFilter(track.filePath)) continue; + hasMatchingTrack = true; + break; + } - if (filteredTracks.isEmpty) continue; + if (!hasMatchingTrack) continue; } result.add(album); @@ -1205,7 +1231,7 @@ class _QueueTabState extends ConsumerState { Set _getAvailableFormats(List items) { final formats = {}; for (final item in items) { - final ext = item.filePath.split('.').last.toLowerCase(); + final ext = _fileExtLower(item.filePath); if (['flac', 'mp3', 'm4a', 'opus', 'ogg', 'wav', 'aiff'].contains(ext)) { formats.add(ext); } @@ -1457,8 +1483,19 @@ class _QueueTabState extends ConsumerState { if (!url.startsWith('http://') && !url.startsWith('https://')) { return; } + final dpr = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 3.0).toDouble(); + final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); precacheImage( - CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance), + ResizeImage( + CachedNetworkImageProvider( + url, + cacheManager: CoverCacheManager.instance, + ), + width: targetSize, + height: targetSize, + ), context, ); } @@ -2272,20 +2309,26 @@ class _QueueTabState extends ConsumerState { Widget _buildQueueItemsSliver(BuildContext context, ColorScheme colorScheme) { return Consumer( builder: (context, ref, child) { - final queueItems = ref.watch( - downloadQueueProvider.select((s) => s.items), + final queueIdsSnapshot = ref.watch( + downloadQueueProvider.select( + (s) => _QueueItemIdsSnapshot( + s.items.map((item) => item.id).toList(growable: false), + ), + ), ); - if (queueItems.isEmpty) { + if (queueIdsSnapshot.ids.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } return SliverList( delegate: SliverChildBuilderDelegate((context, index) { - final item = queueItems[index]; - return KeyedSubtree( - key: ValueKey(item.id), - child: _buildQueueItem(context, item, colorScheme), + final itemId = queueIdsSnapshot.ids[index]; + return _QueueItemSliverRow( + key: ValueKey(itemId), + itemId: itemId, + colorScheme: colorScheme, + itemBuilder: _buildQueueItem, ); - }, childCount: queueItems.length), + }, childCount: queueIdsSnapshot.ids.length), ); }, ); @@ -3953,6 +3996,38 @@ class _QueueTabState extends ConsumerState { } } +class _QueueItemSliverRow extends ConsumerWidget { + final String itemId; + final ColorScheme colorScheme; + final Widget Function(BuildContext, DownloadItem, ColorScheme) itemBuilder; + + const _QueueItemSliverRow({ + super.key, + required this.itemId, + required this.colorScheme, + required this.itemBuilder, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final item = ref.watch( + downloadQueueProvider.select((state) { + for (final current in state.items) { + if (current.id == itemId) { + return current; + } + } + return null; + }), + ); + if (item == null) { + return const SizedBox.shrink(); + } + + return RepaintBoundary(child: itemBuilder(context, item, colorScheme)); + } +} + class _FilterChip extends StatelessWidget { final String label; final int count; diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 0788ecb9..d85d447f 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -26,7 +26,9 @@ class _SearchScreenState extends ConsumerState { if (widget.query.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { final settings = ref.read(settingsProvider); - ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource); + ref + .read(trackProvider.notifier) + .search(widget.query, metadataSource: settings.metadataSource); }); } } @@ -41,19 +43,20 @@ class _SearchScreenState extends ConsumerState { final query = _searchController.text.trim(); if (query.isNotEmpty) { final settings = ref.read(settingsProvider); - ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); + ref + .read(trackProvider.notifier) + .search(query, metadataSource: settings.metadataSource); } } void _downloadTrack(Track track) { final settings = ref.read(settingsProvider); - ref.read(downloadQueueProvider.notifier).addToQueue( - track, - settings.defaultService, - ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Added "${track.name}" to queue')), - ); + ref + .read(downloadQueueProvider.notifier) + .addToQueue(track, settings.defaultService); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); } @override @@ -78,10 +81,7 @@ class _SearchScreenState extends ConsumerState { autofocus: widget.query.isEmpty, ), actions: [ - IconButton( - icon: const Icon(Icons.search), - onPressed: _search, - ), + IconButton(icon: const Icon(Icons.search), onPressed: _search), ], ), body: Column( @@ -92,7 +92,7 @@ class _SearchScreenState extends ConsumerState { Padding( padding: const EdgeInsets.all(16.0), child: Text( - trackState.error!, + trackState.error!, style: TextStyle(color: colorScheme.error), ), ), @@ -115,11 +115,7 @@ class _SearchScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.search, - size: 64, - color: colorScheme.onSurfaceVariant, - ), + Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant), const SizedBox(height: 16), Text( 'Search for tracks', @@ -137,11 +133,13 @@ class _SearchScreenState extends ConsumerState { leading: track.coverUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(8), -child: CachedNetworkImage( + child: CachedNetworkImage( imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, + memCacheWidth: 144, + memCacheHeight: 144, cacheManager: CoverCacheManager.instance, ), ) @@ -152,15 +150,18 @@ child: CachedNetworkImage( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), ), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - track.artistName, - maxLines: 1, + track.artistName, + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 64c88eb8..4131282c 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -384,6 +384,8 @@ class _ContributorItem extends StatelessWidget { width: 40, height: 40, fit: BoxFit.cover, + memCacheWidth: 120, + memCacheHeight: 120, cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: 40, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 730e7f37..1d87551e 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -565,6 +565,13 @@ class _TrackMetadataScreenState extends ConsumerState { double coverSize, bool showContent, ) { + final screenSize = MediaQuery.sizeOf(context); + final pixelRatio = MediaQuery.devicePixelRatioOf(context); + final backgroundCacheWidth = (screenSize.width * pixelRatio).round(); + final backgroundCacheHeight = (screenSize.height * 0.65 * pixelRatio) + .round(); + final coverCacheSize = (coverSize * pixelRatio).round(); + return Stack( fit: StackFit.expand, children: [ @@ -573,12 +580,16 @@ class _TrackMetadataScreenState extends ConsumerState { Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, + cacheWidth: backgroundCacheWidth, + cacheHeight: backgroundCacheHeight, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else if (_coverUrl != null) CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, + memCacheWidth: backgroundCacheWidth, + memCacheHeight: backgroundCacheHeight, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), @@ -587,6 +598,8 @@ class _TrackMetadataScreenState extends ConsumerState { Image.file( File(_localCoverPath!), fit: BoxFit.cover, + cacheWidth: backgroundCacheWidth, + cacheHeight: backgroundCacheHeight, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) else @@ -648,6 +661,8 @@ class _TrackMetadataScreenState extends ConsumerState { ? Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, + cacheWidth: coverCacheSize, + cacheHeight: coverCacheSize, errorBuilder: (_, _, _) => Container( color: colorScheme.surfaceContainerHighest, child: Icon( @@ -673,7 +688,12 @@ class _TrackMetadataScreenState extends ConsumerState { ), ) : _localCoverPath != null && _localCoverPath!.isNotEmpty - ? Image.file(File(_localCoverPath!), fit: BoxFit.cover) + ? Image.file( + File(_localCoverPath!), + fit: BoxFit.cover, + cacheWidth: coverCacheSize, + cacheHeight: coverCacheSize, + ) : Container( color: colorScheme.surfaceContainerHighest, child: Icon( diff --git a/lib/services/downloaded_embedded_cover_resolver.dart b/lib/services/downloaded_embedded_cover_resolver.dart new file mode 100644 index 00000000..0f40bd02 --- /dev/null +++ b/lib/services/downloaded_embedded_cover_resolver.dart @@ -0,0 +1,235 @@ +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; + +class _EmbeddedCoverCacheEntry { + final String previewPath; + final int? sourceModTimeMillis; + + const _EmbeddedCoverCacheEntry({ + required this.previewPath, + this.sourceModTimeMillis, + }); +} + +/// Shared resolver for embedded cover previews from downloaded/local files. +/// It keeps a bounded in-memory cache and only refreshes extraction +/// when the source file changed. +class DownloadedEmbeddedCoverResolver { + static const int _maxCacheEntries = 160; + static const int _minModCheckIntervalMs = 1200; + + static final LinkedHashMap _cache = + LinkedHashMap(); + static final Set _pendingExtract = {}; + static final Set _pendingModCheck = {}; + static final Set _failedExtract = {}; + static final Map _lastModCheckMillis = {}; + + static String cleanFilePath(String? filePath) { + if (filePath == null) return ''; + if (filePath.startsWith('EXISTS:')) { + return filePath.substring(7); + } + return filePath; + } + + static Future readFileModTimeMillis(String? filePath) async { + final cleanPath = cleanFilePath(filePath); + if (cleanPath.isEmpty) return null; + + if (isContentUri(cleanPath)) { + try { + final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]); + return modTimes[cleanPath]; + } catch (_) { + return null; + } + } + + try { + final stat = await File(cleanPath).stat(); + return stat.modified.millisecondsSinceEpoch; + } catch (_) { + return null; + } + } + + static String? resolve(String? filePath, {VoidCallback? onChanged}) { + final cleanPath = cleanFilePath(filePath); + if (cleanPath.isEmpty) return null; + + final cached = _cache[cleanPath]; + if (cached != null) { + if (File(cached.previewPath).existsSync()) { + _touch(cleanPath, cached); + _scheduleModCheck(cleanPath, onChanged: onChanged); + return cached.previewPath; + } + _cache.remove(cleanPath); + _cleanupTempCoverPathSync(cached.previewPath); + } + + _ensureCover(cleanPath, onChanged: onChanged); + return null; + } + + static Future scheduleRefreshForPath( + String? filePath, { + int? beforeModTime, + bool force = false, + VoidCallback? onChanged, + }) async { + final cleanPath = cleanFilePath(filePath); + if (cleanPath.isEmpty) return; + + if (!force) { + if (beforeModTime == null) return; + final afterModTime = await readFileModTimeMillis(cleanPath); + if (afterModTime != null && afterModTime == beforeModTime) { + return; + } + } + + _failedExtract.remove(cleanPath); + _ensureCover(cleanPath, forceRefresh: true, onChanged: onChanged); + } + + static void invalidate(String? filePath) { + final cleanPath = cleanFilePath(filePath); + if (cleanPath.isEmpty) return; + + final cached = _cache.remove(cleanPath); + _pendingExtract.remove(cleanPath); + _pendingModCheck.remove(cleanPath); + _failedExtract.remove(cleanPath); + _lastModCheckMillis.remove(cleanPath); + if (cached != null) { + _cleanupTempCoverPathSync(cached.previewPath); + } + } + + static void _touch(String cleanPath, _EmbeddedCoverCacheEntry entry) { + _cache + ..remove(cleanPath) + ..[cleanPath] = entry; + } + + static void _trimCacheIfNeeded() { + while (_cache.length > _maxCacheEntries) { + final oldestKey = _cache.keys.first; + final removed = _cache.remove(oldestKey); + if (removed != null) { + _cleanupTempCoverPathSync(removed.previewPath); + } + _pendingExtract.remove(oldestKey); + _pendingModCheck.remove(oldestKey); + _failedExtract.remove(oldestKey); + _lastModCheckMillis.remove(oldestKey); + } + } + + static void _scheduleModCheck(String cleanPath, {VoidCallback? onChanged}) { + if (_pendingModCheck.contains(cleanPath)) return; + + final now = DateTime.now().millisecondsSinceEpoch; + final lastCheck = _lastModCheckMillis[cleanPath] ?? 0; + if (now - lastCheck < _minModCheckIntervalMs) return; + _lastModCheckMillis[cleanPath] = now; + + _pendingModCheck.add(cleanPath); + Future.microtask(() async { + try { + final cached = _cache[cleanPath]; + if (cached == null) return; + + final currentModTime = await readFileModTimeMillis(cleanPath); + if (currentModTime != null && + cached.sourceModTimeMillis != null && + currentModTime != cached.sourceModTimeMillis) { + _ensureCover( + cleanPath, + forceRefresh: true, + knownModTime: currentModTime, + onChanged: onChanged, + ); + } + } finally { + _pendingModCheck.remove(cleanPath); + } + }); + } + + static void _ensureCover( + String cleanPath, { + bool forceRefresh = false, + int? knownModTime, + VoidCallback? onChanged, + }) { + if (cleanPath.isEmpty) return; + if (_pendingExtract.contains(cleanPath)) return; + if (!forceRefresh && _cache.containsKey(cleanPath)) return; + if (!forceRefresh && _failedExtract.contains(cleanPath)) return; + + _pendingExtract.add(cleanPath); + Future.microtask(() async { + String? outputPath; + try { + final modTime = knownModTime ?? await readFileModTimeMillis(cleanPath); + final tempDir = await Directory.systemTemp.createTemp( + 'download_cover_preview_', + ); + outputPath = + '${tempDir.path}${Platform.pathSeparator}cover_preview.jpg'; + final result = await PlatformBridge.extractCoverToFile( + cleanPath, + outputPath, + ); + + final hasCover = + result['error'] == null && await File(outputPath).exists(); + if (!hasCover) { + _failedExtract.add(cleanPath); + _cleanupTempCoverPathSync(outputPath); + return; + } + + final previous = _cache[cleanPath]; + final next = _EmbeddedCoverCacheEntry( + previewPath: outputPath, + sourceModTimeMillis: modTime, + ); + _touch(cleanPath, next); + _failedExtract.remove(cleanPath); + _trimCacheIfNeeded(); + + if (previous != null && previous.previewPath != outputPath) { + _cleanupTempCoverPathSync(previous.previewPath); + } + onChanged?.call(); + } catch (_) { + _failedExtract.add(cleanPath); + _cleanupTempCoverPathSync(outputPath); + } finally { + _pendingExtract.remove(cleanPath); + } + }); + } + + static void _cleanupTempCoverPathSync(String? coverPath) { + if (coverPath == null || coverPath.isEmpty) return; + try { + final file = File(coverPath); + if (file.existsSync()) { + file.deleteSync(); + } + final parent = file.parent; + if (parent.existsSync()) { + parent.deleteSync(recursive: true); + } + } catch (_) {} + } +} diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 9c8ef00d..be1caa1e 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -119,10 +119,15 @@ class LogBuffer extends ChangeNotifier { final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); final logs = result['logs'] as List? ?? []; final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; + final keepNonErrorLogs = _loggingEnabled; for (final log in logs) { - final timestamp = log['timestamp'] as String? ?? ''; final level = log['level'] as String? ?? 'INFO'; + if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') { + continue; + } + + final timestamp = log['timestamp'] as String? ?? ''; final tag = log['tag'] as String? ?? 'Go'; final message = log['message'] as String? ?? ''; @@ -372,6 +377,10 @@ class AppLogger { } void _addToBuffer(String level, String message, {String? error}) { + if (!LogBuffer.loggingEnabled && level != 'ERROR' && level != 'FATAL') { + return; + } + LogBuffer().add( LogEntry( timestamp: DateTime.now(),