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