Files
SpotiFLAC-Mobile/lib/screens/track_metadata_screen_cover.dart
T

331 lines
12 KiB
Dart

// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'track_metadata_screen.dart';
// Embedded-cover preview: disk cache keyed by file validation token,
// plus the FFmpeg-backed refresh that extracts the preview image.
extension _TrackMetadataCover on _TrackMetadataScreenState {
bool _isCacheTrackedPath(String? path) {
if (!_hasPath(path)) return false;
return _TrackMetadataScreenState._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_',
);
}
Future<String?> _readLocalFileValidationToken(String path) async {
if (path.isEmpty || isContentUri(path) || _isVolatileSafTempPath(path)) {
return null;
}
try {
final stat = await fileStat(path);
if (stat == null) return null;
return '${stat.modified?.millisecondsSinceEpoch ?? 0}:${stat.size ?? 0}';
} catch (_) {
return null;
}
}
Future<void> _cacheEmbeddedCoverPreview(
String cacheKey,
String sourcePath,
String previewPath,
) async {
final sourceValidationToken = await _readLocalFileValidationToken(
sourcePath,
);
final existing =
_TrackMetadataScreenState._embeddedCoverPreviewCache[cacheKey];
_TrackMetadataScreenState._embeddedCoverPreviewCache[cacheKey] =
_EmbeddedCoverPreviewCacheEntry(
previewPath: previewPath,
sourceValidationToken: sourceValidationToken,
);
if (existing != null && existing.previewPath != previewPath) {
await _cleanupTempFileAndParentIfNotCached(existing.previewPath);
}
while (_TrackMetadataScreenState._embeddedCoverPreviewCache.length >
_TrackMetadataScreenState._maxCoverPreviewCacheEntries) {
final oldestKey =
_TrackMetadataScreenState._embeddedCoverPreviewCache.keys.first;
final removed = _TrackMetadataScreenState._embeddedCoverPreviewCache
.remove(oldestKey);
if (removed != null) {
await _cleanupTempFileAndParentIfNotCached(removed.previewPath);
}
}
}
Future<void> _invalidateEmbeddedCoverPreviewCacheForPath(
String cacheKey,
) async {
if (cacheKey.isEmpty) return;
final removed = _TrackMetadataScreenState._embeddedCoverPreviewCache.remove(
cacheKey,
);
if (removed != null) {
await _cleanupTempFileAndParentIfNotCached(removed.previewPath);
}
}
Future<String?> _getCachedEmbeddedCoverPreviewPathIfValid(
String cacheKey,
String sourcePath,
) async {
if (cacheKey.isEmpty) return null;
final cached =
_TrackMetadataScreenState._embeddedCoverPreviewCache[cacheKey];
if (cached == null) return null;
if (!await fileExists(cached.previewPath)) {
_TrackMetadataScreenState._embeddedCoverPreviewCache.remove(cacheKey);
return null;
}
if (!isContentUri(sourcePath) && !_isVolatileSafTempPath(sourcePath)) {
final currentToken = await _readLocalFileValidationToken(sourcePath);
if (currentToken != null &&
cached.sourceValidationToken != null &&
currentToken != cached.sourceValidationToken) {
_TrackMetadataScreenState._embeddedCoverPreviewCache.remove(cacheKey);
await _cleanupTempFileAndParentIfNotCached(cached.previewPath);
return null;
}
}
return cached.previewPath;
}
Future<void> _refreshEmbeddedCoverPreview({bool force = false}) async {
final generation = _metadataLoadGeneration;
final cacheKey = _coverCacheKey;
final sourcePath = cleanFilePath;
if (!force) {
final cachedPath = await _getCachedEmbeddedCoverPreviewPathIfValid(
cacheKey,
sourcePath,
);
if (_hasPath(cachedPath)) {
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath &&
_embeddedCoverPreviewPath != cachedPath) {
setState(() => _embeddedCoverPreviewPath = cachedPath);
}
return;
}
}
String? newPreviewPath;
try {
if (!_fileExists) {
await _invalidateEmbeddedCoverPreviewCacheForPath(cacheKey);
await _cleanupTempFileAndParentIfNotCached(_embeddedCoverPreviewPath);
if (mounted &&
generation == _metadataLoadGeneration &&
sourcePath == cleanFilePath) {
setState(() => _embeddedCoverPreviewPath = null);
}
return;
}
if (force) {
await _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(
sourcePath,
outputPath,
);
if (result['error'] == null && await File(outputPath).exists()) {
newPreviewPath = outputPath;
await _cacheEmbeddedCoverPreview(cacheKey, sourcePath, outputPath);
} else {
try {
await tempDir.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
final oldPreviewPath = _embeddedCoverPreviewPath;
if (!mounted ||
generation != _metadataLoadGeneration ||
sourcePath != cleanFilePath) {
if (newPreviewPath != null) {
await _cleanupTempFileAndParentIfNotCached(newPreviewPath);
}
return;
}
setState(() => _embeddedCoverPreviewPath = newPreviewPath);
if (oldPreviewPath != null && oldPreviewPath != newPreviewPath) {
await _cleanupTempFileAndParentIfNotCached(oldPreviewPath);
}
}
bool get _isLocalItem => _currentLocalLibraryItem != null;
DownloadHistoryItem? get _downloadItem => _currentDownloadItem;
LocalLibraryItem? get _localLibraryItem => _currentLocalLibraryItem;
bool get _hasHistoryNavigation =>
widget.historyNavigationItems != null && widget.navigationIndex != null;
bool get _hasLocalNavigation =>
widget.localNavigationItems != null && widget.navigationIndex != null;
bool get _hasTrackSwipeNavigation =>
_hasHistoryNavigation || _hasLocalNavigation;
int? get _navigationIndex => _currentNavigationIndex;
int get _navigationLength =>
widget.historyNavigationItems?.length ??
widget.localNavigationItems?.length ??
0;
String get _itemId =>
_isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
String get trackName =>
_editedMetadata?['title']?.toString() ??
(_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName);
String get artistName =>
_editedMetadata?['artist']?.toString() ??
(_isLocalItem
? _localLibraryItem!.artistName
: _downloadItem!.artistName);
String get albumName =>
_editedMetadata?['album']?.toString() ??
(_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName);
String? get albumArtist {
final edited = _editedMetadata?['album_artist']?.toString();
if (edited != null && edited.isNotEmpty) return edited;
return normalizeOptionalString(
_isLocalItem
? _localLibraryItem!.albumArtist
: _downloadItem!.albumArtist,
);
}
int? get trackNumber {
final edited = _editedMetadata?['track_number'];
if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem
? _localLibraryItem!.trackNumber
: _downloadItem!.trackNumber;
}
int? get totalTracks =>
readPositiveInt(_editedMetadata?['total_tracks']) ??
(_isLocalItem
? _localLibraryItem!.totalTracks
: _downloadItem!.totalTracks);
int? get discNumber {
final edited = _editedMetadata?['disc_number'];
if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem
? _localLibraryItem!.discNumber
: _downloadItem!.discNumber;
}
int? get totalDiscs =>
readPositiveInt(_editedMetadata?['total_discs']) ??
(_isLocalItem
? _localLibraryItem!.totalDiscs
: _downloadItem!.totalDiscs);
String? get releaseDate =>
_editedMetadata?['date']?.toString() ??
(_isLocalItem
? _localLibraryItem!.releaseDate
: _downloadItem!.releaseDate);
String? get isrc {
final raw =
_editedMetadata?['isrc']?.toString() ??
(_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc);
if (raw == null || raw.trim().isEmpty) return null;
final upper = raw.trim().toUpperCase();
// Only accept valid ISRC codes (CC-XXX-YY-NNNNN, 12 alphanumeric chars).
// Strip hyphens/spaces that some sources include.
final stripped = upper.replaceAll(RegExp(r'[-\s]'), '');
if (_isrcValidationPattern.hasMatch(stripped)) return stripped;
return null;
}
static final RegExp _isrcValidationPattern = RegExp(
r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$',
);
String? get genre =>
_editedMetadata?['genre']?.toString() ??
(_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
String? get label =>
_editedMetadata?['label']?.toString() ??
(_isLocalItem ? _localLibraryItem!.label : _downloadItem!.label);
String? get copyright =>
_editedMetadata?['copyright']?.toString() ??
(_isLocalItem ? _localLibraryItem!.copyright : _downloadItem!.copyright);
String? get composer =>
_editedMetadata?['composer']?.toString() ??
(_isLocalItem ? _localLibraryItem!.composer : null);
int? get duration =>
readPositiveInt(_editedMetadata?['duration']) ??
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
int? get bitDepth =>
readPositiveInt(_editedMetadata?['bit_depth']) ??
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
int? get sampleRate =>
readPositiveInt(_editedMetadata?['sample_rate']) ??
(_isLocalItem
? _localLibraryItem!.sampleRate
: _downloadItem!.sampleRate);
int? get _audioBitrate =>
_isLocalItem ? _localLibraryItem!.bitrate : _downloadItem?.bitrate;
String? get _storedAudioFormat =>
_resolvedAudioFormat ??
(_isLocalItem ? _localLibraryItem?.format : _downloadItem?.format);
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String get _coverHeroTag =>
widget.coverHeroTag ??
(_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId');
String? get _coverUrl => _isLocalItem
? null
: highResCoverUrl(normalizeRemoteHttpUrl(_downloadItem!.coverUrl));
String? get _localCoverPath =>
_isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
String get _service =>
_isLocalItem ? MusicServices.local : _downloadItem!.service;
DateTime get _addedAt {
if (_isLocalItem) {
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
}
return _localLibraryItem!.scannedAt;
}
return _downloadItem!.downloadedAt;
}
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
int? _readPlausibleBitrateKbps(dynamic value) {
final parsed = readPositiveInt(value);
if (parsed == null) return null;
final kbps = parsed >= 10000 ? (parsed / 1000).round() : parsed;
return kbps >= 16 ? kbps : null;
}
}