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
This commit is contained in:
zarzet
2026-02-11 01:13:24 +07:00
parent bd42655c0e
commit 68e6c8be35
5 changed files with 782 additions and 86 deletions
+23
View File
@@ -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
+8 -1
View File
@@ -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 {
+103 -38
View File
@@ -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<DownloadHistoryState> {
return DownloadHistoryItem.fromJson(json);
}
Future<void> 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<int> cleanupOrphanedDownloads() async {
@@ -2670,8 +2725,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<String, dynamic>) {
@@ -2681,20 +2741,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
}
// 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,
+279 -9
View File
@@ -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<DownloadHistoryItem> 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<QueueTab> {
_HistoryStats? _historyStatsCache;
final Map<String, String> _searchIndexCache = {};
final Map<String, String> _localSearchIndexCache = {};
final Map<String, String> _downloadedEmbeddedCoverCache = {};
final Set<String> _pendingDownloadedCoverExtract = {};
final Set<String> _pendingDownloadedCoverRefresh = {};
final Set<String> _failedDownloadedCoverExtract = {};
Map<String, DownloadHistoryItem> _historyItemsById = {};
List<List<String>> _historyFilterEntries = const [];
Map<String, List<DownloadHistoryItem>> _filteredHistoryCache = const {};
@@ -338,6 +345,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@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<QueueTab> {
'${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<QueueTab> {
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<int?> _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<void> _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<bool> _fileExistsListenable(String? filePath) {
if (filePath == null) return _alwaysMissingFileNotifier;
final cleanPath = _cleanFilePath(filePath);
@@ -1293,7 +1463,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
void _navigateToMetadataScreen(DownloadItem item) {
Future<void> _navigateToMetadataScreen(DownloadItem item) async {
final historyItem = ref
.read(downloadHistoryProvider)
.items
@@ -1311,10 +1481,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
);
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<QueueTab> {
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<void> _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<QueueTab> {
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<QueueTab> {
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<QueueTab> {
_GroupedAlbum album,
ColorScheme colorScheme,
) {
final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath(
album.sampleFilePath,
);
return GestureDetector(
onTap: () => _navigateToDownloadedAlbum(album),
child: Column(
@@ -2584,7 +2790,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
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<QueueTab> {
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) {
+369 -38
View File
@@ -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<TrackMetadataScreen> {
static const int _maxCoverPreviewCacheEntries = 96;
static final Map<String, _EmbeddedCoverPreviewCacheEntry>
_embeddedCoverPreviewCache = {};
bool _fileExists = false;
int? _fileSize;
String? _lyrics; // Cleaned lyrics for display (no timestamps)
@@ -47,6 +61,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<String, dynamic>? _editedMetadata; // Overrides after metadata edit
String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController();
@@ -69,6 +84,93 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'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<TrackMetadataScreen> {
@override
void dispose() {
_cleanupTempFileAndParentSync(_embeddedCoverPreviewPath);
_cleanupTempFileAndParentSyncIfNotCached(_embeddedCoverPreviewPath);
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
@@ -125,6 +227,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
} catch (_) {}
}
Future<void> _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<TrackMetadataScreen> {
} catch (_) {}
}
Future<void> _refreshEmbeddedCoverPreview() async {
void _cleanupTempFileAndParentSyncIfNotCached(String? path) {
if (_isCacheTrackedPath(path)) return;
_cleanupTempFileAndParentSync(path);
}
Future<void> _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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
onPressed: _popWithMetadataResult,
),
actions: [
IconButton(
@@ -1783,7 +1932,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
}
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<TrackMetadataScreen> {
}
}
Future<void> _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 = <String>[];
@@ -2683,7 +2868,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} 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<void> _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<void> _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, {