mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-13 06:30:18 +02:00
feat: resolve audio metadata from file, backfill placeholder quality labels with actual bit depth and sample rate
This commit is contained in:
@@ -784,6 +784,7 @@ func CleanupConnections() {
|
||||
func ReadFileMetadata(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
|
||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||
|
||||
@@ -833,6 +834,12 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
quality, qualityErr := GetM4AQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
}
|
||||
} else if isMp3 {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
|
||||
@@ -251,9 +251,11 @@ class DownloadHistoryState {
|
||||
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
static const int _safRepairBatchSize = 20;
|
||||
static const int _safRepairMaxPerLaunch = 60;
|
||||
static const int _audioMetadataBackfillMaxPerLaunch = 24;
|
||||
final HistoryDatabase _db = HistoryDatabase.instance;
|
||||
bool _isLoaded = false;
|
||||
bool _isSafRepairInProgress = false;
|
||||
bool _isAudioMetadataBackfillInProgress = false;
|
||||
|
||||
@override
|
||||
DownloadHistoryState build() {
|
||||
@@ -298,9 +300,19 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
maxItems: _safRepairMaxPerLaunch,
|
||||
);
|
||||
await cleanupOrphanedDownloads();
|
||||
await _backfillAudioMetadata(
|
||||
state.items,
|
||||
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
Future.microtask(() => cleanupOrphanedDownloads());
|
||||
Future.microtask(() async {
|
||||
await cleanupOrphanedDownloads();
|
||||
await _backfillAudioMetadata(
|
||||
state.items,
|
||||
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e, stack) {
|
||||
_historyLog.e('Failed to load history from database: $e', e, stack);
|
||||
@@ -429,6 +441,157 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
int? _readPositiveInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
bool _supportsAudioMetadataProbe(String filePath) {
|
||||
final trimmed = filePath.trim().toLowerCase();
|
||||
if (trimmed.isEmpty) return false;
|
||||
if (trimmed.startsWith('content://')) return true;
|
||||
return trimmed.endsWith('.flac') ||
|
||||
trimmed.endsWith('.m4a') ||
|
||||
trimmed.endsWith('.aac') ||
|
||||
trimmed.endsWith('.mp3') ||
|
||||
trimmed.endsWith('.opus') ||
|
||||
trimmed.endsWith('.ogg');
|
||||
}
|
||||
|
||||
bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) {
|
||||
if (!_supportsAudioMetadataProbe(item.filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final trimmedPath = item.filePath.trim().toLowerCase();
|
||||
final hasResolvedSpecs =
|
||||
item.bitDepth != null &&
|
||||
item.bitDepth! > 0 &&
|
||||
item.sampleRate != null &&
|
||||
item.sampleRate! > 0;
|
||||
final needsLosslessSpecProbe =
|
||||
!hasResolvedSpecs &&
|
||||
(trimmedPath.endsWith('.flac') ||
|
||||
trimmedPath.endsWith('.m4a') ||
|
||||
trimmedPath.endsWith('.aac') ||
|
||||
trimmedPath.startsWith('content://'));
|
||||
|
||||
if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return needsLosslessSpecProbe ||
|
||||
isPlaceholderQualityLabel(item.quality) ||
|
||||
normalizeOptionalString(item.quality) == null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _probeAudioMetadata(
|
||||
String filePath, {
|
||||
String? fallbackQuality,
|
||||
}) async {
|
||||
if (!_supportsAudioMetadataProbe(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(filePath);
|
||||
if (result['error'] != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final bitDepth = _readPositiveInt(result['bit_depth']);
|
||||
final sampleRate = _readPositiveInt(result['sample_rate']);
|
||||
final quality = buildDisplayAudioQuality(
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
storedQuality: fallbackQuality,
|
||||
);
|
||||
|
||||
if (quality == null && bitDepth == null && sampleRate == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
'quality': quality,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
};
|
||||
} catch (e) {
|
||||
_historyLog.d('Audio metadata probe failed for $filePath: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _backfillAudioMetadata(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required int maxItems,
|
||||
}) async {
|
||||
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_isAudioMetadataBackfillInProgress = true;
|
||||
|
||||
try {
|
||||
var refreshedCount = 0;
|
||||
|
||||
for (final item in items) {
|
||||
if (refreshedCount >= maxItems) {
|
||||
break;
|
||||
}
|
||||
if (!_shouldBackfillAudioMetadata(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final probed = await _probeAudioMetadata(
|
||||
item.filePath,
|
||||
fallbackQuality: item.quality,
|
||||
);
|
||||
if (probed == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final resolvedQuality = normalizeOptionalString(
|
||||
probed['quality'] as String?,
|
||||
);
|
||||
final resolvedBitDepth = probed['bitDepth'] as int?;
|
||||
final resolvedSampleRate = probed['sampleRate'] as int?;
|
||||
|
||||
final qualityChanged =
|
||||
resolvedQuality != null && resolvedQuality != item.quality;
|
||||
final bitDepthChanged =
|
||||
resolvedBitDepth != null && resolvedBitDepth != item.bitDepth;
|
||||
final sampleRateChanged =
|
||||
resolvedSampleRate != null && resolvedSampleRate != item.sampleRate;
|
||||
|
||||
if (!qualityChanged && !bitDepthChanged && !sampleRateChanged) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateAudioMetadataForItem(
|
||||
id: item.id,
|
||||
quality: resolvedQuality,
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
);
|
||||
refreshedCount++;
|
||||
}
|
||||
|
||||
if (refreshedCount > 0) {
|
||||
_historyLog.i(
|
||||
'Audio metadata backfill refreshed $refreshedCount items',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_isAudioMetadataBackfillInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadFromStorage() async {
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
@@ -509,6 +672,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
return DownloadHistoryItem.fromJson(json);
|
||||
}
|
||||
|
||||
Future<void> updateAudioMetadataForItem({
|
||||
required String id,
|
||||
String? quality,
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
}) async {
|
||||
final index = state.items.indexWhere((item) => item.id == id);
|
||||
if (index < 0) return;
|
||||
|
||||
final current = state.items[index];
|
||||
final updated = current.copyWith(
|
||||
quality: quality,
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
);
|
||||
|
||||
if (updated.quality == current.quality &&
|
||||
updated.bitDepth == current.bitDepth &&
|
||||
updated.sampleRate == current.sampleRate) {
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedItems = [...state.items];
|
||||
updatedItems[index] = updated;
|
||||
state = state.copyWith(items: updatedItems);
|
||||
await _db.updateAudioMetadata(
|
||||
id,
|
||||
newQuality: quality,
|
||||
newBitDepth: bitDepth,
|
||||
newSampleRate: sampleRate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateMetadataForItem({
|
||||
required String id,
|
||||
required String trackName,
|
||||
@@ -3496,9 +3692,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final decryptionKey =
|
||||
(result['decryption_key'] as String?)?.trim() ?? '';
|
||||
|
||||
if (!wasExisting &&
|
||||
decryptionKey.isNotEmpty &&
|
||||
filePath != null) {
|
||||
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
|
||||
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
|
||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9);
|
||||
|
||||
@@ -4331,6 +4525,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
normalizeOptionalString(copyright) ??
|
||||
normalizeOptionalString(existingInHistory?.copyright);
|
||||
|
||||
int? finalBitDepth = backendBitDepth;
|
||||
int? finalSampleRate = backendSampleRate;
|
||||
final lowerFilePath = filePath.toLowerCase();
|
||||
final canProbeFinalMetadata =
|
||||
filePath.startsWith('content://') ||
|
||||
lowerFilePath.endsWith('.flac') ||
|
||||
lowerFilePath.endsWith('.m4a') ||
|
||||
lowerFilePath.endsWith('.aac') ||
|
||||
lowerFilePath.endsWith('.mp3') ||
|
||||
lowerFilePath.endsWith('.opus') ||
|
||||
lowerFilePath.endsWith('.ogg');
|
||||
|
||||
if (canProbeFinalMetadata) {
|
||||
try {
|
||||
final metadata = await PlatformBridge.readFileMetadata(filePath);
|
||||
if (metadata['error'] == null) {
|
||||
final probedBitDepth = metadata['bit_depth'] is num
|
||||
? (metadata['bit_depth'] as num).toInt()
|
||||
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
|
||||
final probedSampleRate = metadata['sample_rate'] is num
|
||||
? (metadata['sample_rate'] as num).toInt()
|
||||
: int.tryParse(metadata['sample_rate']?.toString() ?? '');
|
||||
|
||||
if (probedBitDepth != null && probedBitDepth > 0) {
|
||||
finalBitDepth = probedBitDepth;
|
||||
}
|
||||
if (probedSampleRate != null && probedSampleRate > 0) {
|
||||
finalSampleRate = probedSampleRate;
|
||||
}
|
||||
|
||||
final resolvedQuality = buildDisplayAudioQuality(
|
||||
bitDepth: finalBitDepth,
|
||||
sampleRate: finalSampleRate,
|
||||
storedQuality: actualQuality,
|
||||
);
|
||||
if (resolvedQuality != null) {
|
||||
actualQuality = resolvedQuality;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.d('Final audio metadata probe failed for $filePath: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||
|
||||
final historyAlbumArtist =
|
||||
@@ -4338,9 +4576,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
? resolvedAlbumArtist
|
||||
: null;
|
||||
|
||||
final isMp3 = filePath.endsWith('.mp3');
|
||||
final historyBitDepth = isMp3 ? null : backendBitDepth;
|
||||
final historySampleRate = isMp3 ? null : backendSampleRate;
|
||||
final isLossyOutput =
|
||||
lowerFilePath.endsWith('.mp3') ||
|
||||
lowerFilePath.endsWith('.opus') ||
|
||||
lowerFilePath.endsWith('.ogg');
|
||||
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
|
||||
final historySampleRate = isLossyOutput ? null : finalSampleRate;
|
||||
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
|
||||
@@ -29,6 +29,7 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
enum LibraryItemSource { downloaded, local }
|
||||
|
||||
@@ -70,7 +71,11 @@ class UnifiedLibraryItem {
|
||||
albumName: item.albumName,
|
||||
coverUrl: item.coverUrl,
|
||||
filePath: item.filePath,
|
||||
quality: item.quality,
|
||||
quality: buildDisplayAudioQuality(
|
||||
bitDepth: item.bitDepth,
|
||||
sampleRate: item.sampleRate,
|
||||
storedQuality: item.quality,
|
||||
),
|
||||
addedAt: item.downloadedAt,
|
||||
source: LibraryItemSource.downloaded,
|
||||
historyItem: item,
|
||||
@@ -80,15 +85,18 @@ class UnifiedLibraryItem {
|
||||
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
|
||||
String? quality;
|
||||
if (item.bitrate != null && item.bitrate! > 0) {
|
||||
// Lossy format with bitrate
|
||||
final fmt = item.format?.toUpperCase() ?? '';
|
||||
quality = '$fmt ${item.bitrate}kbps'.trim();
|
||||
quality = buildDisplayAudioQuality(
|
||||
bitrateKbps: item.bitrate,
|
||||
format: item.format,
|
||||
);
|
||||
} else if (item.bitDepth != null &&
|
||||
item.bitDepth! > 0 &&
|
||||
item.sampleRate != null) {
|
||||
// Lossless format with actual bit depth
|
||||
quality =
|
||||
'${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||
quality = buildDisplayAudioQuality(
|
||||
bitDepth: item.bitDepth,
|
||||
sampleRate: item.sampleRate,
|
||||
);
|
||||
}
|
||||
return UnifiedLibraryItem(
|
||||
id: 'local_${item.id}',
|
||||
|
||||
@@ -66,6 +66,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _isInstrumental = false; // Track if detected as instrumental
|
||||
bool _isConverting = false; // Track convert operation in progress
|
||||
bool _hasMetadataChanges = false;
|
||||
bool _hasLoadedResolvedAudioMetadata = false;
|
||||
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
|
||||
String? _embeddedCoverPreviewPath;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
@@ -240,6 +241,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (mounted && exists && _lyrics == null && !_lyricsLoading) {
|
||||
_fetchLyrics();
|
||||
}
|
||||
if (mounted &&
|
||||
exists &&
|
||||
!_isLocalItem &&
|
||||
!_hasLoadedResolvedAudioMetadata) {
|
||||
unawaited(_refreshResolvedAudioMetadataFromFile());
|
||||
}
|
||||
if (mounted && exists && !_hasPath(_embeddedCoverPreviewPath)) {
|
||||
final cachedPath = _getCachedEmbeddedCoverPreviewPathIfValid(
|
||||
_coverCacheKey,
|
||||
@@ -274,6 +281,61 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
await _cleanupTempFileAndParent(path);
|
||||
}
|
||||
|
||||
Future<void> _refreshResolvedAudioMetadataFromFile() async {
|
||||
if (_isLocalItem ||
|
||||
_downloadItem == null ||
|
||||
_hasLoadedResolvedAudioMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
_hasLoadedResolvedAudioMetadata = true;
|
||||
|
||||
try {
|
||||
final metadata = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
if (metadata['error'] != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
|
||||
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
|
||||
final resolvedQuality = buildDisplayAudioQuality(
|
||||
bitDepth: resolvedBitDepth ?? bitDepth,
|
||||
sampleRate: resolvedSampleRate ?? sampleRate,
|
||||
storedQuality: _quality,
|
||||
);
|
||||
final shouldPersistResolvedAudioMetadata =
|
||||
resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null);
|
||||
|
||||
if ((resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
isPlaceholderQualityLabel(_quality)) &&
|
||||
mounted) {
|
||||
setState(() {
|
||||
_editedMetadata = {
|
||||
...?_editedMetadata,
|
||||
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
|
||||
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldPersistResolvedAudioMetadata) {
|
||||
await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.updateAudioMetadataForItem(
|
||||
id: _downloadItem!.id,
|
||||
quality: resolvedQuality,
|
||||
bitDepth: resolvedBitDepth,
|
||||
sampleRate: resolvedSampleRate,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to resolve audio metadata from file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanupTempFileAndParentSync(String? path) {
|
||||
if (!_hasPath(path)) return;
|
||||
final file = File(path!);
|
||||
@@ -426,9 +488,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
int? get duration =>
|
||||
_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
|
||||
int? get bitDepth =>
|
||||
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
|
||||
_readPositiveInt(_editedMetadata?['bit_depth']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
|
||||
int? get sampleRate =>
|
||||
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
|
||||
_readPositiveInt(_editedMetadata?['sample_rate']) ??
|
||||
(_isLocalItem
|
||||
? _localLibraryItem!.sampleRate
|
||||
: _downloadItem!.sampleRate);
|
||||
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
|
||||
|
||||
String get _filePath =>
|
||||
@@ -452,6 +518,32 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
String? get _quality => _isLocalItem ? null : _downloadItem!.quality;
|
||||
|
||||
int? _readPositiveInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is num) {
|
||||
final asInt = value.toInt();
|
||||
return asInt > 0 ? asInt : null;
|
||||
}
|
||||
final parsed = int.tryParse(value.toString());
|
||||
if (parsed == null || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
String? get _displayAudioQuality {
|
||||
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
|
||||
final fileExt = fileName.contains('.')
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: null;
|
||||
|
||||
return buildDisplayAudioQuality(
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
bitrateKbps: _isLocalItem ? _localBitrate : null,
|
||||
format: _isLocalItem ? (_localLibraryItem!.format ?? fileExt) : fileExt,
|
||||
storedQuality: _quality,
|
||||
);
|
||||
}
|
||||
|
||||
String get cleanFilePath {
|
||||
final path = _filePath;
|
||||
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
||||
@@ -723,7 +815,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (_quality != null && _quality!.isNotEmpty)
|
||||
if (_displayAudioQuality != null &&
|
||||
_displayAudioQuality!.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
@@ -734,7 +827,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_quality!,
|
||||
_displayAudioQuality!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -961,34 +1054,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Widget _buildMetadataGrid(BuildContext context, ColorScheme colorScheme) {
|
||||
// Determine audio quality string - prefer stored quality from download
|
||||
String? audioQualityStr;
|
||||
final fileName = _extractFileNameFromPathOrUri(cleanFilePath);
|
||||
final fileExt = fileName.contains('.')
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: '';
|
||||
|
||||
// Use stored quality from download history if available
|
||||
if (_quality != null && _quality!.isNotEmpty) {
|
||||
audioQualityStr = _quality;
|
||||
} else if (_isLocalItem && _localBitrate != null && _localBitrate! > 0) {
|
||||
// Lossy local file with bitrate info
|
||||
final fmt = _localLibraryItem!.format?.toUpperCase() ?? fileExt;
|
||||
audioQualityStr = '$fmt ${_localBitrate}kbps';
|
||||
} else if (bitDepth != null && bitDepth! > 0 && sampleRate != null) {
|
||||
// Lossless file with actual bit depth (FLAC, ALAC)
|
||||
final sampleRateKHz = (sampleRate! / 1000).toStringAsFixed(1);
|
||||
audioQualityStr = '$bitDepth-bit/${sampleRateKHz}kHz';
|
||||
} else {
|
||||
// Fallback based on file extension for legacy items
|
||||
if (fileExt == 'MP3') {
|
||||
audioQualityStr = 'MP3';
|
||||
} else if (fileExt == 'OPUS' || fileExt == 'OGG') {
|
||||
audioQualityStr = 'Opus';
|
||||
} else if (fileExt == 'M4A' || fileExt == 'AAC') {
|
||||
audioQualityStr = 'AAC';
|
||||
}
|
||||
}
|
||||
final audioQualityStr = _displayAudioQuality;
|
||||
|
||||
final items = <_MetadataItem>[
|
||||
_MetadataItem(context.l10n.trackTrackName, trackName),
|
||||
@@ -1090,7 +1156,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final fileExtension = fileName.contains('.')
|
||||
? fileName.split('.').last.toUpperCase()
|
||||
: 'Unknown';
|
||||
final lossyBitrateLabel = _extractLossyBitrateLabel(_quality);
|
||||
final resolvedQuality = _displayAudioQuality;
|
||||
final lossyBitrateLabel = _extractLossyBitrateLabel(resolvedQuality);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
@@ -1220,7 +1287,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'$bitDepth-bit/${(sampleRate! / 1000).toStringAsFixed(1)}kHz',
|
||||
buildDisplayAudioQuality(
|
||||
bitDepth: bitDepth,
|
||||
sampleRate: sampleRate,
|
||||
) ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -498,6 +498,29 @@ class HistoryDatabase {
|
||||
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<void> updateAudioMetadata(
|
||||
String id, {
|
||||
String? newQuality,
|
||||
int? newBitDepth,
|
||||
int? newSampleRate,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final values = <String, dynamic>{};
|
||||
if (newQuality != null) {
|
||||
values['quality'] = newQuality;
|
||||
}
|
||||
if (newBitDepth != null) {
|
||||
values['bit_depth'] = newBitDepth;
|
||||
}
|
||||
if (newSampleRate != null) {
|
||||
values['sample_rate'] = newSampleRate;
|
||||
}
|
||||
if (values.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Get all file paths from download history
|
||||
/// Used to exclude downloaded files from local library scan
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
|
||||
@@ -5,3 +5,47 @@ String? normalizeOptionalString(String? value) {
|
||||
if (trimmed.toLowerCase() == 'null') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String formatSampleRateKHz(int sampleRate) {
|
||||
final khz = sampleRate / 1000;
|
||||
final precision = sampleRate % 1000 == 0 ? 0 : 1;
|
||||
return '${khz.toStringAsFixed(precision)}kHz';
|
||||
}
|
||||
|
||||
String? buildDisplayAudioQuality({
|
||||
int? bitDepth,
|
||||
int? sampleRate,
|
||||
int? bitrateKbps,
|
||||
String? format,
|
||||
String? storedQuality,
|
||||
}) {
|
||||
if (bitrateKbps != null && bitrateKbps > 0) {
|
||||
final normalizedFormat = normalizeOptionalString(format)?.toUpperCase();
|
||||
return normalizedFormat != null
|
||||
? '$normalizedFormat ${bitrateKbps}kbps'
|
||||
: '${bitrateKbps}kbps';
|
||||
}
|
||||
|
||||
if (bitDepth != null &&
|
||||
bitDepth > 0 &&
|
||||
sampleRate != null &&
|
||||
sampleRate > 0) {
|
||||
return '$bitDepth-bit/${formatSampleRateKHz(sampleRate)}';
|
||||
}
|
||||
|
||||
return normalizeOptionalString(storedQuality);
|
||||
}
|
||||
|
||||
bool isPlaceholderQualityLabel(String? quality) {
|
||||
final normalized = normalizeOptionalString(quality)?.toLowerCase();
|
||||
if (normalized == null) return false;
|
||||
|
||||
return const {
|
||||
'best',
|
||||
'lossless',
|
||||
'hi-res',
|
||||
'hi-res-max',
|
||||
'high',
|
||||
'cd',
|
||||
}.contains(normalized);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user