perf: memory and rebuild optimizations across app

- Bound Deezer cache with LRU eviction and periodic cleanup
- Configure Flutter image cache limits (240 entries / 60 MiB)
- Add ResizeImage wrapper for precacheImage calls
- Add memCacheWidth/cacheWidth to cover images across screens
- Add DownloadedEmbeddedCoverResolver as centralized cover service
- Throttle download progress notifications with dedup checks
- Normalize progress/speed/bytes values to reduce UI rebuilds
- Optimize queue list with per-item ConsumerWidget and RepaintBoundary
- Preserve derived indexes in LocalLibraryState.copyWith
- Skip non-error logs when detailed logging disabled
- Use async file stat and early-break loops in queue filters
This commit is contained in:
zarzet
2026-02-11 01:44:05 +07:00
parent 68e6c8be35
commit a9150b85b9
14 changed files with 891 additions and 140 deletions
+129 -15
View File
@@ -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)
+9
View File
@@ -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});
+87 -11
View File
@@ -673,6 +673,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<SharedPreferences> _prefs = SharedPreferences.getInstance();
int _totalQueuedAtStart = 0;
@@ -686,6 +687,55 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
} 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<DownloadQueueState> {
}
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<DownloadQueueState> {
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<DownloadQueueState> {
_lastServicePercent = -1;
_lastServiceQueueCount = -1;
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
_lastFinalizingTrackName = null;
_lastFinalizingArtistName = null;
_lastNotifTrackName = null;
_lastNotifArtistName = null;
_lastNotifPercent = -1;
_lastNotifQueueCount = -1;
}
Future<void> _initOutputDir() async {
+50 -18
View File
@@ -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<String>? isrcSet,
Set<String>? trackKeySet,
Map<String, LocalLibraryItem>? 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<LocalLibraryState> {
_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();
+8
View File
@@ -268,6 +268,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
(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<AlbumScreen> {
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
+82 -8
View File
@@ -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<DownloadedAlbumScreen> {
}
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
void _onEmbeddedCoverChanged() {
if (!mounted) return;
setState(() {});
}
Future<void> _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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
String? _resolveAlbumEmbeddedCoverPath(List<DownloadHistoryItem> tracks) {
if (tracks.isEmpty) return null;
return DownloadedEmbeddedCoverResolver.resolve(
tracks.first.filePath,
onChanged: _onEmbeddedCoverChanged,
);
}
Widget _buildAppBar(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
@@ -294,6 +336,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
(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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
),
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,
+116 -28
View File
@@ -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<RecentAccessItem> uniqueItems;
final List<RecentAccessItem> downloadItems;
final Map<String, String> 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<HomeTab>
),
// 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<HomeTab>
);
}
void _onEmbeddedCoverChanged() {
if (!mounted) return;
setState(() {});
}
Widget _buildRecentDownloads(
List<DownloadHistoryItem> items,
ColorScheme colorScheme,
@@ -1049,6 +1059,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
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<HomeTab>
}
final downloadItems = <RecentAccessItem>[];
final downloadFilePathByRecentKey = <String, String>{};
for (final entry in albumGroups.entries) {
final tracks = entry.value;
final mostRecent = tracks.reduce(
@@ -1136,29 +1170,31 @@ class _HomeTabState extends ConsumerState<HomeTab>
: 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<HomeTab>
final view = _RecentAccessView(
uniqueItems: uniqueItems,
downloadItems: downloadItems,
downloadFilePathByRecentKey: downloadFilePathByRecentKey,
hasHiddenDownloads: hiddenIds.isNotEmpty,
);
@@ -1680,7 +1717,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
)
else
...uniqueItems.map(
(item) => _buildRecentAccessItem(item, colorScheme),
(item) => _buildRecentAccessItem(
item,
colorScheme,
view.downloadFilePathByRecentKey,
),
),
],
),
@@ -1690,10 +1731,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
Widget _buildRecentAccessItem(
RecentAccessItem item,
ColorScheme colorScheme,
Map<String, String> 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<HomeTab>
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<HomeTab>
}
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
Future<void> _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<HomeTab>
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<HomeTab>
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,
);
}
+8
View File
@@ -181,6 +181,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
(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<PlaylistScreen> {
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
+110 -35
View File
@@ -210,6 +210,20 @@ class _UnifiedCacheEntry {
});
}
class _QueueItemIdsSnapshot {
final List<String> 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<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
final entries = (payload['entries'] as List).cast<List>();
final albumCounts = (payload['albumCounts'] as Map).cast<String, int>();
@@ -722,10 +736,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
}
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<QueueTab> {
});
}
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<UnifiedLibraryItem> _applyAdvancedFilters(
List<UnifiedLibraryItem> items,
) {
@@ -1024,7 +1055,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
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<QueueTab> {
/// 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<QueueTab> {
// 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<QueueTab> {
// 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<QueueTab> {
Set<String> _getAvailableFormats(List<UnifiedLibraryItem> items) {
final formats = <String>{};
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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
}
}
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;
+24 -23
View File
@@ -26,7 +26,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
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<SearchScreen> {
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<SearchScreen> {
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<SearchScreen> {
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<SearchScreen> {
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<SearchScreen> {
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),
),
+2
View File
@@ -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,
+21 -1
View File
@@ -565,6 +565,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
? 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<TrackMetadataScreen> {
),
)
: _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(
@@ -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<String, _EmbeddedCoverCacheEntry> _cache =
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
static final Set<String> _pendingExtract = <String>{};
static final Set<String> _pendingModCheck = <String>{};
static final Set<String> _failedExtract = <String>{};
static final Map<String, int> _lastModCheckMillis = <String, int>{};
static String cleanFilePath(String? filePath) {
if (filePath == null) return '';
if (filePath.startsWith('EXISTS:')) {
return filePath.substring(7);
}
return filePath;
}
static Future<int?> 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<void> 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 (_) {}
}
}
+10 -1
View File
@@ -119,10 +119,15 @@ class LogBuffer extends ChangeNotifier {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
final logs = result['logs'] as List<dynamic>? ?? [];
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(),