From 68e6c8be35ca50286ec0634e975636e181f3617d Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 11 Feb 2026 01:13:24 +0700 Subject: [PATCH] ui: improve cover preview in edit metadata sheet and user changes - Cover preview enlarged from 120x120 to 160x160 with shadow and better styling - Layout changed from Wrap to Row with Expanded for side-by-side covers - Label moved below image with labelMedium typography - Cover editor section moved to top of edit form - Added embedded cover preview cache with LRU eviction in metadata screen - Added current cover extraction and preview in edit metadata sheet - Added metadata sync to download history after edits - Added embedded cover extraction cache in queue tab for downloaded items - Added SAF mod-time tracking for cover refresh after metadata changes --- CHANGELOG.md | 23 ++ go_backend/audio_metadata.go | 9 +- lib/providers/download_queue_provider.dart | 141 +++++-- lib/screens/queue_tab.dart | 288 ++++++++++++++- lib/screens/track_metadata_screen.dart | 407 +++++++++++++++++++-- 5 files changed, 782 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 314a9930..61557dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ - Automatic fallback in Spotify metadata fetch path when primary source fails - Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC - Includes heuristic detection of lyrics stored in Comment fields +- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save +- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started ### Changed @@ -43,6 +45,8 @@ - Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support - Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback - Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow) +- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags +- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview) ### Fixed @@ -58,6 +62,25 @@ - Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback` - Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc` - YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers +- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long` +- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write +- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension +- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available +- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks + - Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`) + - Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library + - Extraction is now on-demand for edited tracks only (not full-library reload) + - Returning from Track Metadata now refreshes cover cache only for the affected track + - Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen +- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening + - Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract + - Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions + - Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache + - Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files + - Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced) +- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows + - Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan + - Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable ### Technical diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index a681c88a..e743d352 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -911,9 +911,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { break } - if commentLen > 10000 { + remaining := uint32(reader.Len()) + if commentLen > remaining { break } + // Large comment entries are typically METADATA_BLOCK_PICTURE. + // Skip them so we can continue parsing normal text tags after/before. + if commentLen > 512*1024 { + reader.Seek(int64(commentLen), io.SeekCurrent) + continue + } comment := make([]byte, commentLen) if _, err := reader.Read(comment); err != nil { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5d5de696..4cce155a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -151,20 +151,37 @@ class DownloadHistoryItem { ); DownloadHistoryItem copyWith({ + String? trackName, + String? artistName, + String? albumName, + String? albumArtist, + String? coverUrl, String? filePath, String? storageMode, String? downloadTreeUri, String? safRelativeDir, String? safFileName, bool? safRepaired, + String? isrc, + String? spotifyId, + int? trackNumber, + int? discNumber, + int? duration, + String? releaseDate, + String? quality, + int? bitDepth, + int? sampleRate, + String? genre, + String? label, + String? copyright, }) { return DownloadHistoryItem( id: id, - trackName: trackName, - artistName: artistName, - albumName: albumName, - albumArtist: albumArtist, - coverUrl: coverUrl, + trackName: trackName ?? this.trackName, + artistName: artistName ?? this.artistName, + albumName: albumName ?? this.albumName, + albumArtist: albumArtist ?? this.albumArtist, + coverUrl: coverUrl ?? this.coverUrl, filePath: filePath ?? this.filePath, storageMode: storageMode ?? this.storageMode, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, @@ -173,18 +190,18 @@ class DownloadHistoryItem { safRepaired: safRepaired ?? this.safRepaired, service: service, downloadedAt: downloadedAt, - isrc: isrc, - spotifyId: spotifyId, - trackNumber: trackNumber, - discNumber: discNumber, - duration: duration, - releaseDate: releaseDate, - quality: quality, - bitDepth: bitDepth, - sampleRate: sampleRate, - genre: genre, - label: label, - copyright: copyright, + isrc: isrc ?? this.isrc, + spotifyId: spotifyId ?? this.spotifyId, + trackNumber: trackNumber ?? this.trackNumber, + discNumber: discNumber ?? this.discNumber, + duration: duration ?? this.duration, + releaseDate: releaseDate ?? this.releaseDate, + quality: quality ?? this.quality, + bitDepth: bitDepth ?? this.bitDepth, + sampleRate: sampleRate ?? this.sampleRate, + genre: genre ?? this.genre, + label: label ?? this.label, + copyright: copyright ?? this.copyright, ); } } @@ -463,6 +480,44 @@ class DownloadHistoryNotifier extends Notifier { return DownloadHistoryItem.fromJson(json); } + Future updateMetadataForItem({ + required String id, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? isrc, + int? trackNumber, + int? discNumber, + String? releaseDate, + String? genre, + String? label, + String? copyright, + }) async { + final index = state.items.indexWhere((item) => item.id == id); + if (index < 0) return; + + final current = state.items[index]; + final updated = current.copyWith( + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist, + isrc: isrc, + trackNumber: trackNumber, + discNumber: discNumber, + releaseDate: releaseDate, + genre: genre, + label: label, + copyright: copyright, + ); + + final updatedItems = [...state.items]; + updatedItems[index] = updated; + state = state.copyWith(items: updatedItems); + await _db.upsert(updated.toJson()); + } + /// Remove history entries where the file no longer exists on disk /// Returns the number of orphaned entries removed Future cleanupOrphanedDownloads() async { @@ -2670,8 +2725,13 @@ class DownloadQueueNotifier extends Notifier { if (spotifyId.startsWith('spotify:track:')) { spotifyId = spotifyId.split(':').last; } - _log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId'); - final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId); + _log.d( + 'No Deezer ID, converting from Spotify via SongLink: $spotifyId', + ); + final deezerData = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); // Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}} final trackData = deezerData['track']; if (trackData is Map) { @@ -2681,20 +2741,29 @@ class DownloadQueueNotifier extends Notifier { _log.d('Found Deezer track ID via SongLink: $deezerTrackId'); } else if (deezerData['id'] != null) { deezerTrackId = deezerData['id'].toString(); - _log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId'); + _log.d( + 'Found Deezer track ID via SongLink (legacy): $deezerTrackId', + ); } // Enrich track metadata from Deezer response (release_date, isrc, etc.) - final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?); - final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?); + final deezerReleaseDate = _normalizeOptionalString( + trackData['release_date'] as String?, + ); + final deezerIsrc = _normalizeOptionalString( + trackData['isrc'] as String?, + ); final deezerTrackNum = trackData['track_number'] as int?; final deezerDiscNum = trackData['disc_number'] as int?; final needsEnrich = - (trackToDownload.releaseDate == null && deezerReleaseDate != null) || + (trackToDownload.releaseDate == null && + deezerReleaseDate != null) || (trackToDownload.isrc == null && deezerIsrc != null) || - (!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) || - (trackToDownload.trackNumber == null && deezerTrackNum != null) || + (!_isValidISRC(trackToDownload.isrc ?? '') && + deezerIsrc != null) || + (trackToDownload.trackNumber == null && + deezerTrackNum != null) || (trackToDownload.discNumber == null && deezerDiscNum != null); if (needsEnrich) { @@ -2717,7 +2786,9 @@ class DownloadQueueNotifier extends Notifier { albumType: trackToDownload.albumType, source: trackToDownload.source, ); - _log.d('Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}'); + _log.d( + 'Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}', + ); } } else if (deezerData['id'] != null) { deezerTrackId = deezerData['id'].toString(); @@ -2909,14 +2980,8 @@ class DownloadQueueNotifier extends Notifier { decryptionKey.isNotEmpty && filePath != null && actualService == 'amazon') { - _log.i( - 'Amazon encrypted stream detected, decrypting via FFmpeg...', - ); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.9, - ); + _log.i('Amazon encrypted stream detected, decrypting via FFmpeg...'); + updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9); if (effectiveSafMode && isContentUri(filePath)) { final currentFilePath = filePath; @@ -3485,14 +3550,14 @@ class DownloadQueueNotifier extends Notifier { } // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt - if (!wasExisting && - item.service == 'youtube' && - filePath != null) { + if (!wasExisting && item.service == 'youtube' && filePath != null) { final isOpusFile = filePath.endsWith('.opus'); final isMp3File = filePath.endsWith('.mp3'); if (isOpusFile || isMp3File) { - _log.i('YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file'); + _log.i( + 'YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file', + ); updateItemStatus( item.id, DownloadStatus.downloading, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index ad967413..95c886dc 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; @@ -105,6 +106,7 @@ class _GroupedAlbum { final String albumName; final String artistName; final String? coverUrl; + final String sampleFilePath; final List tracks; final DateTime latestDownload; final String searchKey; @@ -113,6 +115,7 @@ class _GroupedAlbum { required this.albumName, required this.artistName, this.coverUrl, + required this.sampleFilePath, required this.tracks, required this.latestDownload, }) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}'; @@ -290,6 +293,10 @@ class _QueueTabState extends ConsumerState { _HistoryStats? _historyStatsCache; final Map _searchIndexCache = {}; final Map _localSearchIndexCache = {}; + final Map _downloadedEmbeddedCoverCache = {}; + final Set _pendingDownloadedCoverExtract = {}; + final Set _pendingDownloadedCoverRefresh = {}; + final Set _failedDownloadedCoverExtract = {}; Map _historyItemsById = {}; List> _historyFilterEntries = const []; Map> _filteredHistoryCache = const {}; @@ -338,6 +345,13 @@ class _QueueTabState extends ConsumerState { @override void dispose() { + for (final coverPath in _downloadedEmbeddedCoverCache.values) { + _cleanupTempCoverPathSync(coverPath); + } + _downloadedEmbeddedCoverCache.clear(); + _pendingDownloadedCoverExtract.clear(); + _pendingDownloadedCoverRefresh.clear(); + _failedDownloadedCoverExtract.clear(); for (final notifier in _fileExistsNotifiers.values) { notifier.dispose(); } @@ -405,6 +419,19 @@ class _QueueTabState extends ConsumerState { '${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}'; return [item.id, albumKey, searchKey]; }, growable: false); + + if (historyChanged) { + final validPaths = items + .map((item) => _cleanFilePath(item.filePath)) + .where((path) => path.isNotEmpty) + .toSet(); + final staleKeys = _downloadedEmbeddedCoverCache.keys + .where((path) => !validPaths.contains(path)) + .toList(growable: false); + for (final key in staleKeys) { + _invalidateDownloadedEmbeddedCover(key); + } + } _requestFilterRefresh(); } @@ -745,6 +772,149 @@ class _QueueTabState extends ConsumerState { return filePath; } + 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 (_) {} + } + + void _invalidateDownloadedEmbeddedCover(String? filePath) { + final cleanPath = _cleanFilePath(filePath); + if (cleanPath.isEmpty) return; + + final cachedPath = _downloadedEmbeddedCoverCache.remove(cleanPath); + _pendingDownloadedCoverExtract.remove(cleanPath); + _pendingDownloadedCoverRefresh.remove(cleanPath); + _failedDownloadedCoverExtract.remove(cleanPath); + _cleanupTempCoverPathSync(cachedPath); + } + + Future _readFileModTimeMillis(String? filePath) async { + final cleanPath = _cleanFilePath(filePath); + if (cleanPath.isEmpty) return null; + + if (cleanPath.startsWith('content://')) { + try { + final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]); + return modTimes[cleanPath]; + } catch (_) { + return null; + } + } + + try { + return File(cleanPath).statSync().modified.millisecondsSinceEpoch; + } catch (_) { + return null; + } + } + + Future _scheduleDownloadedEmbeddedCoverRefreshForPath( + String? filePath, { + int? beforeModTime, + bool force = false, + }) 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; + } + } + + _pendingDownloadedCoverRefresh.add(cleanPath); + _failedDownloadedCoverExtract.remove(cleanPath); + if (mounted) { + setState(() {}); + } + } + + String? _resolveDownloadedEmbeddedCoverPath(String? filePath) { + final cleanPath = _cleanFilePath(filePath); + if (cleanPath.isEmpty) return null; + + if (_pendingDownloadedCoverRefresh.remove(cleanPath)) { + _ensureDownloadedEmbeddedCover(cleanPath, forceRefresh: true); + } + + final cachedPath = _downloadedEmbeddedCoverCache[cleanPath]; + if (cachedPath != null) { + if (File(cachedPath).existsSync()) { + return cachedPath; + } + _downloadedEmbeddedCoverCache.remove(cleanPath); + _cleanupTempCoverPathSync(cachedPath); + } + + return null; + } + + void _ensureDownloadedEmbeddedCover( + String cleanPath, { + bool forceRefresh = false, + }) { + if (cleanPath.isEmpty) return; + if (_pendingDownloadedCoverExtract.contains(cleanPath)) return; + if (!forceRefresh && _downloadedEmbeddedCoverCache.containsKey(cleanPath)) { + return; + } + if (!forceRefresh && _failedDownloadedCoverExtract.contains(cleanPath)) { + return; + } + + _pendingDownloadedCoverExtract.add(cleanPath); + Future.microtask(() async { + String? outputPath; + try { + final tempDir = await Directory.systemTemp.createTemp('library_cover_'); + outputPath = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final result = await PlatformBridge.extractCoverToFile( + cleanPath, + outputPath, + ); + + final hasCover = + result['error'] == null && await File(outputPath).exists(); + if (!hasCover) { + _failedDownloadedCoverExtract.add(cleanPath); + _cleanupTempCoverPathSync(outputPath); + return; + } + + if (!mounted) { + _cleanupTempCoverPathSync(outputPath); + return; + } + + final previous = _downloadedEmbeddedCoverCache[cleanPath]; + _downloadedEmbeddedCoverCache[cleanPath] = outputPath; + _failedDownloadedCoverExtract.remove(cleanPath); + if (previous != null && previous != outputPath) { + _cleanupTempCoverPathSync(previous); + } + setState(() {}); + } catch (_) { + _failedDownloadedCoverExtract.add(cleanPath); + _cleanupTempCoverPathSync(outputPath); + } finally { + _pendingDownloadedCoverExtract.remove(cleanPath); + } + }); + } + ValueListenable _fileExistsListenable(String? filePath) { if (filePath == null) return _alwaysMissingFileNotifier; final cleanPath = _cleanFilePath(filePath); @@ -1293,7 +1463,7 @@ class _QueueTabState extends ConsumerState { ); } - void _navigateToMetadataScreen(DownloadItem item) { + Future _navigateToMetadataScreen(DownloadItem item) async { final historyItem = ref .read(downloadHistoryProvider) .items @@ -1311,10 +1481,12 @@ class _QueueTabState extends ConsumerState { ), ); + final navigator = Navigator.of(context); _precacheCover(historyItem.coverUrl); _searchFocusNode.unfocus(); - Navigator.push( - context, + final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); + if (!mounted) return; + final result = await navigator.push( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -1323,14 +1495,31 @@ class _QueueTabState extends ConsumerState { transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), - ).then((_) => _searchFocusNode.unfocus()); + ); + _searchFocusNode.unfocus(); + if (result == true) { + await _scheduleDownloadedEmbeddedCoverRefreshForPath( + historyItem.filePath, + beforeModTime: beforeModTime, + force: true, + ); + return; + } + await _scheduleDownloadedEmbeddedCoverRefreshForPath( + historyItem.filePath, + beforeModTime: beforeModTime, + ); } - void _navigateToHistoryMetadataScreen(DownloadHistoryItem item) { + Future _navigateToHistoryMetadataScreen( + DownloadHistoryItem item, + ) async { + final navigator = Navigator.of(context); _precacheCover(item.coverUrl); _searchFocusNode.unfocus(); - Navigator.push( - context, + final beforeModTime = await _readFileModTimeMillis(item.filePath); + if (!mounted) return; + final result = await navigator.push( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -1339,7 +1528,20 @@ class _QueueTabState extends ConsumerState { transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), - ).then((_) => _searchFocusNode.unfocus()); + ); + _searchFocusNode.unfocus(); + if (result == true) { + await _scheduleDownloadedEmbeddedCoverRefreshForPath( + item.filePath, + beforeModTime: beforeModTime, + force: true, + ); + return; + } + await _scheduleDownloadedEmbeddedCoverRefreshForPath( + item.filePath, + beforeModTime: beforeModTime, + ); } void _navigateToLocalMetadataScreen(LocalLibraryItem item) { @@ -1434,6 +1636,7 @@ class _QueueTabState extends ConsumerState { albumName: tracks.first.albumName, artistName: tracks.first.albumArtist ?? tracks.first.artistName, coverUrl: tracks.first.coverUrl, + sampleFilePath: tracks.first.filePath, tracks: tracks, latestDownload: tracks .map((t) => t.downloadedAt) @@ -2574,6 +2777,9 @@ class _QueueTabState extends ConsumerState { _GroupedAlbum album, ColorScheme colorScheme, ) { + final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( + album.sampleFilePath, + ); return GestureDetector( onTap: () => _navigateToDownloadedAlbum(album), child: Column( @@ -2584,7 +2790,27 @@ class _QueueTabState extends ConsumerState { children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: album.coverUrl != null + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 48, + ), + ), + ), + ) + : album.coverUrl != null ? CachedNetworkImage( imageUrl: album.coverUrl!, fit: BoxFit.cover, @@ -3154,6 +3380,26 @@ class _QueueTabState extends ConsumerState { double size, ) { final isDownloaded = item.source == LibraryItemSource.downloaded; + if (isDownloaded) { + final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( + item.filePath, + ); + if (embeddedCoverPath != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(embeddedCoverPath), + width: size, + height: size, + fit: BoxFit.cover, + cacheWidth: (size * 2).toInt(), + cacheHeight: (size * 2).toInt(), + errorBuilder: (context, error, stackTrace) => + _buildPlaceholderCover(colorScheme, size, isDownloaded), + ), + ); + } + } // Network URL cover (downloaded items) if (item.coverUrl != null) { @@ -3235,6 +3481,30 @@ class _QueueTabState extends ConsumerState { ColorScheme colorScheme, ) { final isDownloaded = item.source == LibraryItemSource.downloaded; + if (isDownloaded) { + final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( + item.filePath, + ); + if (embeddedCoverPath != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + cacheWidth: 200, + cacheHeight: 200, + errorBuilder: (context, error, stackTrace) => Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), + ), + ), + ); + } + } // Network URL cover (downloaded items) if (item.coverUrl != null) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 18fa4127..730e7f37 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -20,6 +20,16 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('TrackMetadata'); +class _EmbeddedCoverPreviewCacheEntry { + final String previewPath; + final int? fileModTime; + + const _EmbeddedCoverPreviewCacheEntry({ + required this.previewPath, + this.fileModTime, + }); +} + class TrackMetadataScreen extends ConsumerStatefulWidget { final DownloadHistoryItem? item; final LocalLibraryItem? localItem; @@ -36,6 +46,10 @@ class TrackMetadataScreen extends ConsumerStatefulWidget { } class _TrackMetadataScreenState extends ConsumerState { + static const int _maxCoverPreviewCacheEntries = 96; + static final Map + _embeddedCoverPreviewCache = {}; + bool _fileExists = false; int? _fileSize; String? _lyrics; // Cleaned lyrics for display (no timestamps) @@ -47,6 +61,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool _isEmbedding = false; // Track embed operation in progress bool _isInstrumental = false; // Track if detected as instrumental bool _isConverting = false; // Track convert operation in progress + bool _hasMetadataChanges = false; Map? _editedMetadata; // Overrides after metadata edit String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); @@ -69,6 +84,93 @@ class _TrackMetadataScreenState extends ConsumerState { 'Dec', ]; + String get _coverCacheKey => _itemId; + + bool _isCacheTrackedPath(String? path) { + if (!_hasPath(path)) return false; + return _embeddedCoverPreviewCache.values.any( + (entry) => entry.previewPath == path, + ); + } + + bool _isVolatileSafTempPath(String path) { + if (path.isEmpty) return false; + return path.contains( + '${Platform.pathSeparator}cache${Platform.pathSeparator}saf_', + ); + } + + int? _readLocalFileModTimeMsSync(String path) { + if (path.isEmpty || isContentUri(path) || _isVolatileSafTempPath(path)) { + return null; + } + try { + return File(path).statSync().modified.millisecondsSinceEpoch; + } catch (_) { + return null; + } + } + + void _cacheEmbeddedCoverPreview( + String cacheKey, + String sourcePath, + String previewPath, + ) { + final fileModTime = _readLocalFileModTimeMsSync(sourcePath); + final existing = _embeddedCoverPreviewCache[cacheKey]; + _embeddedCoverPreviewCache[cacheKey] = _EmbeddedCoverPreviewCacheEntry( + previewPath: previewPath, + fileModTime: fileModTime, + ); + if (existing != null && existing.previewPath != previewPath) { + _cleanupTempFileAndParentSyncIfNotCached(existing.previewPath); + } + + while (_embeddedCoverPreviewCache.length > _maxCoverPreviewCacheEntries) { + final oldestKey = _embeddedCoverPreviewCache.keys.first; + final removed = _embeddedCoverPreviewCache.remove(oldestKey); + if (removed != null) { + _cleanupTempFileAndParentSyncIfNotCached(removed.previewPath); + } + } + } + + void _invalidateEmbeddedCoverPreviewCacheForPath(String cacheKey) { + if (cacheKey.isEmpty) return; + final removed = _embeddedCoverPreviewCache.remove(cacheKey); + if (removed != null) { + _cleanupTempFileAndParentSyncIfNotCached(removed.previewPath); + } + } + + String? _getCachedEmbeddedCoverPreviewPathIfValid( + String cacheKey, + String sourcePath, + ) { + if (cacheKey.isEmpty) return null; + final cached = _embeddedCoverPreviewCache[cacheKey]; + if (cached == null) return null; + + final previewFile = File(cached.previewPath); + if (!previewFile.existsSync()) { + _embeddedCoverPreviewCache.remove(cacheKey); + return null; + } + + if (!isContentUri(sourcePath) && !_isVolatileSafTempPath(sourcePath)) { + final currentModTime = _readLocalFileModTimeMsSync(sourcePath); + if (currentModTime != null && + cached.fileModTime != null && + currentModTime != cached.fileModTime) { + _embeddedCoverPreviewCache.remove(cacheKey); + _cleanupTempFileAndParentSyncIfNotCached(cached.previewPath); + return null; + } + } + + return cached.previewPath; + } + String? _normalizeOptionalString(String? value) { if (value == null) return null; final trimmed = value.trim(); @@ -86,7 +188,7 @@ class _TrackMetadataScreenState extends ConsumerState { @override void dispose() { - _cleanupTempFileAndParentSync(_embeddedCoverPreviewPath); + _cleanupTempFileAndParentSyncIfNotCached(_embeddedCoverPreviewPath); _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); @@ -125,6 +227,15 @@ class _TrackMetadataScreenState extends ConsumerState { if (mounted && exists && _lyrics == null && !_lyricsLoading) { _fetchLyrics(); } + if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) { + final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid( + _coverCacheKey, + cleanFilePath, + ); + if (_hasPath(cachedPath)) { + setState(() => _embeddedCoverPreviewPath = cachedPath); + } + } } bool _hasPath(String? path) => path != null && path.trim().isNotEmpty; @@ -145,6 +256,11 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} } + Future _cleanupTempFileAndParentIfNotCached(String? path) async { + if (_isCacheTrackedPath(path)) return; + await _cleanupTempFileAndParent(path); + } + void _cleanupTempFileAndParentSync(String? path) { if (!_hasPath(path)) return; final file = File(path!); @@ -161,27 +277,52 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} } - Future _refreshEmbeddedCoverPreview() async { + void _cleanupTempFileAndParentSyncIfNotCached(String? path) { + if (_isCacheTrackedPath(path)) return; + _cleanupTempFileAndParentSync(path); + } + + Future _refreshEmbeddedCoverPreview({bool force = false}) async { + final cacheKey = _coverCacheKey; + final sourcePath = cleanFilePath; + if (!force) { + final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid( + cacheKey, + sourcePath, + ); + if (_hasPath(cachedPath)) { + if (mounted && _embeddedCoverPreviewPath != cachedPath) { + setState(() => _embeddedCoverPreviewPath = cachedPath); + } + return; + } + } + String? newPreviewPath; try { if (!_fileExists) { - await _cleanupTempFileAndParent(_embeddedCoverPreviewPath); + _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath); if (mounted) { setState(() => _embeddedCoverPreviewPath = null); } return; } + if (force) { + _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey); + } final tempDir = await Directory.systemTemp.createTemp( 'track_cover_preview_', ); final outputPath = '${tempDir.path}${Platform.pathSeparator}cover_preview.jpg'; final result = await PlatformBridge.extractCoverToFile( - cleanFilePath, + sourcePath, outputPath, ); if (result['error'] == null && await File(outputPath).exists()) { newPreviewPath = outputPath; + _cacheEmbeddedCoverPreview(cacheKey, sourcePath, outputPath); } else { try { await tempDir.delete(recursive: true); @@ -192,14 +333,14 @@ class _TrackMetadataScreenState extends ConsumerState { final oldPreviewPath = _embeddedCoverPreviewPath; if (!mounted) { if (newPreviewPath != null) { - await _cleanupTempFileAndParent(newPreviewPath); + await _cleanupTempFileAndParentIfNotCached(newPreviewPath); } return; } setState(() => _embeddedCoverPreviewPath = newPreviewPath); if (oldPreviewPath != null && oldPreviewPath != newPreviewPath) { - await _cleanupTempFileAndParent(oldPreviewPath); + await _cleanupTempFileAndParentIfNotCached(oldPreviewPath); } } @@ -292,6 +433,14 @@ class _TrackMetadataScreenState extends ConsumerState { return path.startsWith('EXISTS:') ? path.substring(7) : path; } + void _markMetadataChanged() { + _hasMetadataChanges = true; + } + + void _popWithMetadataResult() { + Navigator.pop(context, _hasMetadataChanges ? true : null); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -354,7 +503,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), child: Icon(Icons.arrow_back, color: colorScheme.onSurface), ), - onPressed: () => Navigator.pop(context), + onPressed: _popWithMetadataResult, ), actions: [ IconButton( @@ -1783,7 +1932,9 @@ class _TrackMetadataScreenState extends ConsumerState { if (method == 'native') { // FLAC - handled natively by Go (SAF write-back handled in Kotlin) - await _refreshEmbeddedCoverPreview(); + await _refreshEmbeddedCoverPreview(force: true); + _markMetadataChanged(); + await _syncDownloadHistoryMetadata(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), @@ -1879,7 +2030,9 @@ class _TrackMetadataScreenState extends ConsumerState { } if (ffmpegResult != null) { - await _refreshEmbeddedCoverPreview(); + await _refreshEmbeddedCoverPreview(force: true); + _markMetadataChanged(); + await _syncDownloadHistoryMetadata(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), @@ -1917,6 +2070,38 @@ class _TrackMetadataScreenState extends ConsumerState { } } + Future _syncDownloadHistoryMetadata() async { + if (_isLocalItem || _downloadItem == null) return; + + String? normalizedOrNull(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + return trimmed; + } + + try { + await ref + .read(downloadHistoryProvider.notifier) + .updateMetadataForItem( + id: _downloadItem!.id, + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: normalizedOrNull(albumArtist), + isrc: normalizedOrNull(isrc), + trackNumber: trackNumber, + discNumber: discNumber, + releaseDate: normalizedOrNull(releaseDate), + genre: normalizedOrNull(genre), + label: normalizedOrNull(label), + copyright: normalizedOrNull(copyright), + ); + } catch (e) { + _log.w('Failed to sync download history metadata: $e'); + } + } + String _cleanLrcForDisplay(String lrc) { final lines = lrc.split('\n'); final cleanLines = []; @@ -2683,7 +2868,9 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) { setState(() {}); } - await _refreshEmbeddedCoverPreview(); + await _refreshEmbeddedCoverPreview(force: true); + _markMetadataChanged(); + await _syncDownloadHistoryMetadata(); } } @@ -2864,6 +3051,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _selectedCoverPath; String? _selectedCoverTempDir; String? _selectedCoverName; + String? _currentCoverPath; + String? _currentCoverTempDir; + bool _loadingCurrentCover = false; late final TextEditingController _titleCtrl; late final TextEditingController _artistCtrl; @@ -2879,6 +3069,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { late final TextEditingController _composerCtrl; late final TextEditingController _commentCtrl; + bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; + String _resolveImageExtension(String? ext, Uint8List? bytes) { final normalized = (ext ?? '').toLowerCase(); if (normalized == 'png' || @@ -2940,6 +3132,72 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } catch (_) {} } + void _cleanupCurrentCoverTempSync() { + final dirPath = _currentCoverTempDir; + _currentCoverPath = null; + _currentCoverTempDir = null; + if (dirPath == null || dirPath.isEmpty) return; + try { + final dir = Directory(dirPath); + if (dir.existsSync()) { + dir.deleteSync(recursive: true); + } + } catch (_) {} + } + + Future _loadCurrentCoverPreview() async { + if (_loadingCurrentCover) return; + setState(() => _loadingCurrentCover = true); + String? newCoverPath; + String? newCoverDir; + try { + final tempDir = await Directory.systemTemp.createTemp( + 'edit_existing_cover_', + ); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}existing_cover.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + widget.filePath, + coverOutput, + ); + if (coverResult['error'] == null && await File(coverOutput).exists()) { + newCoverPath = coverOutput; + newCoverDir = tempDir.path; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + + if (!mounted) { + if (newCoverDir != null) { + try { + final dir = Directory(newCoverDir); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + return; + } + + final oldDir = _currentCoverTempDir; + setState(() { + _currentCoverPath = newCoverPath; + _currentCoverTempDir = newCoverDir; + _loadingCurrentCover = false; + }); + if (oldDir != null && oldDir.isNotEmpty && oldDir != newCoverDir) { + try { + final dir = Directory(oldDir); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } catch (_) {} + } + } + Future _pickCoverImage() async { try { final result = await FilePicker.platform.pickFiles( @@ -3007,11 +3265,13 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _copyrightCtrl = TextEditingController(text: v['copyright'] ?? ''); _composerCtrl = TextEditingController(text: v['composer'] ?? ''); _commentCtrl = TextEditingController(text: v['comment'] ?? ''); + _loadCurrentCoverPreview(); } @override void dispose() { _cleanupSelectedCoverTempSync(); + _cleanupCurrentCoverTempSync(); _titleCtrl.dispose(); _artistCtrl.dispose(); _albumCtrl.dispose(); @@ -3120,7 +3380,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { vorbisMap['COMMENT'] = metadata['comment']!; } - String? existingCoverPath = _selectedCoverPath; + String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath; String? extractedCoverPath; if (existingCoverPath == null || existingCoverPath.isEmpty) { // Preserve current embedded cover when user does not pick a new one. @@ -3274,6 +3534,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { padding: const EdgeInsets.symmetric(horizontal: 24), children: [ const SizedBox(height: 6), + _buildCoverEditor(cs), _field('Title', _titleCtrl), _field('Artist', _artistCtrl), _field('Album', _albumCtrl), @@ -3300,7 +3561,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), _field('Genre', _genreCtrl), _field('ISRC', _isrcCtrl), - _buildCoverEditor(cs), // Advanced fields toggle Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), @@ -3347,8 +3607,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } Widget _buildCoverEditor(ColorScheme cs) { - final hasSelectedCover = - _selectedCoverPath != null && _selectedCoverPath!.isNotEmpty; + final hasSelectedCover = _hasValue(_selectedCoverPath); + final hasCurrentCover = _hasValue(_currentCoverPath); return Padding( padding: const EdgeInsets.only(bottom: 12), child: Container( @@ -3367,6 +3627,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { context, ).textTheme.labelLarge?.copyWith(color: cs.onSurface), ), + const SizedBox(height: 6), + if (_loadingCurrentCover) + const LinearProgressIndicator(minHeight: 2) + else if (!hasCurrentCover) + Text( + 'No embedded album art found', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), const SizedBox(height: 8), Row( children: [ @@ -3395,32 +3665,39 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ], ], ), - if (hasSelectedCover) ...[ - const SizedBox(height: 8), - Text( - _selectedCoverName ?? 'Selected cover', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (hasCurrentCover || hasSelectedCover) ...[ + const SizedBox(height: 12), + Row( + children: [ + if (hasCurrentCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _currentCoverPath!, + label: 'Current cover', + ), + ), + if (hasCurrentCover && hasSelectedCover) + const SizedBox(width: 12), + if (hasSelectedCover) + Expanded( + child: _buildCoverPreviewTile( + cs: cs, + path: _selectedCoverPath!, + label: _selectedCoverName ?? 'Selected cover', + ), + ), + ], ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Image.file( - File(_selectedCoverPath!), - height: 120, - width: 120, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - width: 120, - height: 120, - color: cs.surfaceContainerHighest, - child: Icon(Icons.broken_image, color: cs.onSurfaceVariant), - ), + if (hasSelectedCover) ...[ + const SizedBox(height: 8), + Text( + 'The selected cover will replace the current embedded cover when you tap Save.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), - ), + ], ], ], ), @@ -3428,6 +3705,60 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } + Widget _buildCoverPreviewTile({ + required ColorScheme cs, + required String path, + required String label, + }) { + return Column( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: cs.shadow.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(path), + height: 160, + width: 160, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 160, + height: 160, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.broken_image, + color: cs.onSurfaceVariant, + size: 32, + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: cs.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + Widget _field( String label, TextEditingController controller, {