mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
331 lines
12 KiB
Dart
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;
|
|
}
|
|
}
|