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