mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-09 22:18:45 +02:00
fix: preserve extended metadata during fallback, accurate lossy quality display, SAF improvements
- Add Genre/Label/Copyright fields to DownloadResult struct - buildDownloadSuccessResponse now prefers service result metadata over request - enrichRequestExtendedMetadata fetches Deezer metadata by ISRC before download - Flutter sends copyright in download request payload - History merge preserves existing genre/label/copyright on re-download - Accurate MP3 duration via Xing/VBRI VBR headers, MPEG2/2.5 bitrate tables - Accurate Opus/Vorbis duration via last Ogg page granule position - Bitrate field added to LibraryScanResult, LocalLibraryItem, DB v4 migration - Lossy formats display format+bitrate instead of fake 16-bit quality - Local library file date uses fileModTime instead of scannedAt - SAF URI recovery for transient FD paths after download - Improved SAF repair and download history path matching in library scan - Extract quality probe logic into reusable enrichResultQualityFromFile
This commit is contained in:
@@ -323,7 +323,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
|
||||
final hasFilePath = item.filePath.trim().isNotEmpty;
|
||||
final hasSafFileName =
|
||||
item.safFileName != null && item.safFileName!.trim().isNotEmpty;
|
||||
if (!hasFilePath && !hasSafFileName) {
|
||||
continue;
|
||||
}
|
||||
candidateIndexes.add(i);
|
||||
@@ -344,52 +347,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
for (var c = 0; c < candidateIndexes.length; c++) {
|
||||
final i = candidateIndexes[c];
|
||||
final item = items[i];
|
||||
final rawPath = item.filePath.trim();
|
||||
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
|
||||
|
||||
final exists = await fileExists(item.filePath);
|
||||
if (exists) {
|
||||
final verified = item.copyWith(
|
||||
safRepaired: true,
|
||||
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
|
||||
);
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
await _db.upsert(verified.toJson());
|
||||
} else {
|
||||
final fallbackName =
|
||||
item.safFileName ?? _fileNameFromUri(item.filePath);
|
||||
if (fallbackName.isEmpty) {
|
||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||
if (isDirectSafUri) {
|
||||
final exists = await fileExists(rawPath);
|
||||
if (exists) {
|
||||
final verified = item.copyWith(
|
||||
safRepaired: true,
|
||||
safFileName: item.safFileName ?? _fileNameFromUri(rawPath),
|
||||
);
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
await _db.upsert(verified.toJson());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final newUri = resolved['uri'] as String? ?? '';
|
||||
if (newUri.isEmpty) continue;
|
||||
var fallbackName = (item.safFileName ?? '').trim();
|
||||
if (fallbackName.isEmpty && isDirectSafUri) {
|
||||
fallbackName = _fileNameFromUri(rawPath);
|
||||
}
|
||||
if (fallbackName.isEmpty) {
|
||||
_historyLog.w('Missing SAF filename for history item: ${item.id}');
|
||||
continue;
|
||||
}
|
||||
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir:
|
||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: fallbackName,
|
||||
safRepaired: true,
|
||||
);
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: item.downloadTreeUri!,
|
||||
relativeDir: item.safRelativeDir ?? '',
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final newUri = (resolved['uri'] as String? ?? '').trim();
|
||||
if (newUri.isEmpty) continue;
|
||||
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
repairedCount++;
|
||||
await _db.upsert(updated.toJson());
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
final newRelativeDir = resolved['relative_dir'] as String?;
|
||||
final updated = item.copyWith(
|
||||
filePath: newUri,
|
||||
safRelativeDir:
|
||||
(newRelativeDir != null && newRelativeDir.isNotEmpty)
|
||||
? newRelativeDir
|
||||
: item.safRelativeDir,
|
||||
safFileName: fallbackName,
|
||||
safRepaired: true,
|
||||
);
|
||||
|
||||
updatedItems[i] = updated;
|
||||
changed = true;
|
||||
repairedCount++;
|
||||
await _db.upsert(updated.toJson());
|
||||
} catch (e) {
|
||||
_historyLog.w('Failed to repair SAF URI: $e');
|
||||
}
|
||||
|
||||
if ((c + 1) % _safRepairBatchSize == 0) {
|
||||
@@ -421,19 +431,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
existing = state.getByIsrc(item.isrc!);
|
||||
}
|
||||
|
||||
final mergedItem = existing == null
|
||||
? item
|
||||
: item.copyWith(
|
||||
genre:
|
||||
_normalizeOptionalString(item.genre) ??
|
||||
_normalizeOptionalString(existing.genre),
|
||||
label:
|
||||
_normalizeOptionalString(item.label) ??
|
||||
_normalizeOptionalString(existing.label),
|
||||
copyright:
|
||||
_normalizeOptionalString(item.copyright) ??
|
||||
_normalizeOptionalString(existing.copyright),
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
final updatedItems = state.items
|
||||
.where((i) => i.id != existing!.id)
|
||||
.toList();
|
||||
updatedItems.insert(0, item);
|
||||
updatedItems.insert(0, mergedItem);
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||
_historyLog.d('Updated existing history entry: ${mergedItem.trackName}');
|
||||
} else {
|
||||
state = state.copyWith(items: [item, ...state.items]);
|
||||
_historyLog.d('Added new history entry: ${item.trackName}');
|
||||
state = state.copyWith(items: [mergedItem, ...state.items]);
|
||||
_historyLog.d('Added new history entry: ${mergedItem.trackName}');
|
||||
}
|
||||
|
||||
_db.upsert(item.toJson()).catchError((e) {
|
||||
_db.upsert(mergedItem.toJson()).catchError((e) {
|
||||
_historyLog.e('Failed to save to database: $e');
|
||||
});
|
||||
}
|
||||
@@ -2768,6 +2792,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
String? genre;
|
||||
String? label;
|
||||
String? copyright;
|
||||
|
||||
String? deezerTrackId = trackToDownload.deezerId;
|
||||
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
|
||||
@@ -2889,8 +2914,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (extendedMetadata != null) {
|
||||
genre = extendedMetadata['genre'];
|
||||
label = extendedMetadata['label'];
|
||||
copyright = extendedMetadata['copyright'];
|
||||
if (genre != null && genre.isNotEmpty) {
|
||||
_log.d('Extended metadata - Genre: $genre, Label: $label');
|
||||
_log.d(
|
||||
'Extended metadata - Genre: $genre, Label: $label, Copyright: $copyright',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -2960,6 +2988,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
source: trackToDownload.source ?? '',
|
||||
genre: genre ?? '',
|
||||
label: label ?? '',
|
||||
copyright: copyright ?? '',
|
||||
deezerId: deezerTrackId ?? '',
|
||||
lyricsMode: settings.lyricsMode,
|
||||
storageMode: storageMode,
|
||||
@@ -3748,6 +3777,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// SAF downloads should end with content URI. If we still have a
|
||||
// transient FD path, recover URI from SAF metadata to keep history
|
||||
// dedup/exclusion stable.
|
||||
if (effectiveSafMode &&
|
||||
filePath != null &&
|
||||
filePath.isNotEmpty &&
|
||||
!isContentUri(filePath) &&
|
||||
settings.downloadTreeUri.isNotEmpty) {
|
||||
final fallbackName = (finalSafFileName ?? safFileName ?? '').trim();
|
||||
if (fallbackName.isNotEmpty) {
|
||||
try {
|
||||
final resolved = await PlatformBridge.resolveSafFile(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: fallbackName,
|
||||
);
|
||||
final resolvedUri = (resolved['uri'] as String? ?? '').trim();
|
||||
final resolvedRelativeDir =
|
||||
(resolved['relative_dir'] as String? ?? '').trim();
|
||||
if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) {
|
||||
_log.w('Recovered SAF URI from transient path: $filePath');
|
||||
filePath = resolvedUri;
|
||||
finalSafFileName = fallbackName;
|
||||
if (resolvedRelativeDir.isNotEmpty) {
|
||||
effectiveOutputDir = resolvedRelativeDir;
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF URI recovery failed: $e');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'SAF download returned non-URI path without filename metadata: $filePath',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.completed,
|
||||
@@ -3840,6 +3910,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
final effectiveGenre =
|
||||
_normalizeOptionalString(backendGenre) ??
|
||||
_normalizeOptionalString(genre) ??
|
||||
_normalizeOptionalString(existingInHistory?.genre);
|
||||
final effectiveLabel =
|
||||
_normalizeOptionalString(backendLabel) ??
|
||||
_normalizeOptionalString(label) ??
|
||||
_normalizeOptionalString(existingInHistory?.label);
|
||||
final effectiveCopyright =
|
||||
_normalizeOptionalString(backendCopyright) ??
|
||||
_normalizeOptionalString(copyright) ??
|
||||
_normalizeOptionalString(existingInHistory?.copyright);
|
||||
|
||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||
|
||||
@@ -3899,9 +3981,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
quality: actualQuality,
|
||||
bitDepth: historyBitDepth,
|
||||
sampleRate: historySampleRate,
|
||||
genre: backendGenre,
|
||||
label: backendLabel,
|
||||
copyright: backendCopyright,
|
||||
genre: effectiveGenre,
|
||||
label: effectiveLabel,
|
||||
copyright: effectiveCopyright,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -180,6 +181,58 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
await _loadFromDatabase();
|
||||
}
|
||||
|
||||
Set<String> _buildPathMatchKeys(String? filePath) {
|
||||
final raw = filePath?.trim() ?? '';
|
||||
if (raw.isEmpty) return const {};
|
||||
|
||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
|
||||
final keys = <String>{cleaned};
|
||||
|
||||
void addNormalized(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return;
|
||||
keys.add(trimmed);
|
||||
keys.add(trimmed.toLowerCase());
|
||||
if (trimmed.contains('\\')) {
|
||||
final slash = trimmed.replaceAll('\\', '/');
|
||||
keys.add(slash);
|
||||
keys.add(slash.toLowerCase());
|
||||
}
|
||||
if (trimmed.contains('%')) {
|
||||
try {
|
||||
final decoded = Uri.decodeFull(trimmed);
|
||||
keys.add(decoded);
|
||||
keys.add(decoded.toLowerCase());
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
addNormalized(cleaned);
|
||||
|
||||
if (cleaned.startsWith('content://')) {
|
||||
try {
|
||||
final uri = Uri.parse(cleaned);
|
||||
addNormalized(uri.toString());
|
||||
addNormalized(uri.replace(query: null, fragment: null).toString());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
|
||||
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final candidateKeys = _buildPathMatchKeys(filePath);
|
||||
for (final key in candidateKeys) {
|
||||
if (downloadedPathKeys.contains(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> startScan(
|
||||
String folderPath, {
|
||||
bool forceFullScan = false,
|
||||
@@ -217,10 +270,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
try {
|
||||
final isSaf = folderPath.startsWith('content://');
|
||||
|
||||
// Get all file paths from download history to exclude them
|
||||
// Get all file paths from download history to exclude them.
|
||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
||||
// been flushed to SQLite yet.
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
final inMemoryHistoryPaths = ref
|
||||
.read(downloadHistoryProvider)
|
||||
.items
|
||||
.map((item) => item.filePath)
|
||||
.where((path) => path.isNotEmpty);
|
||||
final allHistoryPaths = <String>{
|
||||
...downloadedPaths,
|
||||
...inMemoryHistoryPaths,
|
||||
};
|
||||
final downloadedPathKeys = <String>{};
|
||||
for (final path in allHistoryPaths) {
|
||||
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
|
||||
}
|
||||
_log.i(
|
||||
'Excluding ${downloadedPaths.length} downloaded files from library scan',
|
||||
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
|
||||
'(${downloadedPathKeys.length} path keys)',
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
@@ -238,7 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
for (final json in results) {
|
||||
final filePath = json['filePath'] as String?;
|
||||
// Skip files that are already in download history
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
@@ -344,7 +413,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
for (final json in scannedList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
final filePath = map['filePath'] as String?;
|
||||
if (filePath != null && downloadedPaths.contains(filePath)) {
|
||||
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
|
||||
skippedDownloads++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -560,7 +560,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
final first = tracks.first;
|
||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
||||
|
||||
// For lossy formats, use bitrate
|
||||
if (first.bitrate != null && first.bitrate! > 0) {
|
||||
final fmt = first.format?.toUpperCase() ?? '';
|
||||
final firstBitrate = first.bitrate;
|
||||
for (final track in tracks) {
|
||||
if (track.bitrate != firstBitrate) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return '$fmt ${firstBitrate}kbps'.trim();
|
||||
}
|
||||
|
||||
// For lossless formats, use bit depth / sample rate
|
||||
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
|
||||
|
||||
final firstQuality =
|
||||
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
||||
|
||||
@@ -70,7 +70,12 @@ class UnifiedLibraryItem {
|
||||
|
||||
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
|
||||
String? quality;
|
||||
if (item.bitDepth != null && item.sampleRate != null) {
|
||||
if (item.bitrate != null && item.bitrate! > 0) {
|
||||
// Lossy format with bitrate
|
||||
final fmt = item.format?.toUpperCase() ?? '';
|
||||
quality = '$fmt ${item.bitrate}kbps'.trim();
|
||||
} 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';
|
||||
}
|
||||
@@ -897,7 +902,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
String? _localQualityLabel(LocalLibraryItem item) {
|
||||
if (item.bitDepth == null || item.sampleRate == null) {
|
||||
if (item.bitrate != null && item.bitrate! > 0) {
|
||||
return '${item.bitrate}kbps';
|
||||
}
|
||||
if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) {
|
||||
return null;
|
||||
}
|
||||
return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz';
|
||||
|
||||
@@ -416,6 +416,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
|
||||
int? get sampleRate =>
|
||||
_isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
|
||||
int? get _localBitrate => _isLocalItem ? _localLibraryItem!.bitrate : null;
|
||||
|
||||
String get _filePath =>
|
||||
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
||||
@@ -424,8 +425,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
||||
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
|
||||
DateTime get _addedAt =>
|
||||
_isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt;
|
||||
DateTime get _addedAt {
|
||||
if (_isLocalItem) {
|
||||
// Use file modification time if available, otherwise fall back to scannedAt
|
||||
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;
|
||||
|
||||
String get cleanFilePath {
|
||||
@@ -921,8 +931,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
// Use stored quality from download history if available
|
||||
if (_quality != null && _quality!.isNotEmpty) {
|
||||
audioQualityStr = _quality;
|
||||
} else if (bitDepth != null && sampleRate != null) {
|
||||
// Fallback for FLAC files without stored 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 {
|
||||
@@ -1128,7 +1142,31 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (bitDepth != null && sampleRate != null)
|
||||
else if (_isLocalItem &&
|
||||
_localBitrate != null &&
|
||||
_localBitrate! > 0 &&
|
||||
(fileExtension == 'MP3' ||
|
||||
fileExtension == 'OPUS' ||
|
||||
fileExtension == 'OGG'))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'${_localBitrate}kbps',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (bitDepth != null && bitDepth! > 0 && sampleRate != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
|
||||
@@ -23,6 +23,7 @@ class LocalLibraryItem {
|
||||
final String? releaseDate;
|
||||
final int? bitDepth;
|
||||
final int? sampleRate;
|
||||
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
|
||||
final String? genre;
|
||||
final String? format; // flac, mp3, opus, m4a
|
||||
|
||||
@@ -43,6 +44,7 @@ class LocalLibraryItem {
|
||||
this.releaseDate,
|
||||
this.bitDepth,
|
||||
this.sampleRate,
|
||||
this.bitrate,
|
||||
this.genre,
|
||||
this.format,
|
||||
});
|
||||
@@ -64,6 +66,7 @@ class LocalLibraryItem {
|
||||
'releaseDate': releaseDate,
|
||||
'bitDepth': bitDepth,
|
||||
'sampleRate': sampleRate,
|
||||
'bitrate': bitrate,
|
||||
'genre': genre,
|
||||
'format': format,
|
||||
};
|
||||
@@ -86,6 +89,7 @@ class LocalLibraryItem {
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
bitDepth: json['bitDepth'] as int?,
|
||||
sampleRate: json['sampleRate'] as int?,
|
||||
bitrate: (json['bitrate'] as num?)?.toInt(),
|
||||
genre: json['genre'] as String?,
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
@@ -115,7 +119,7 @@ class LibraryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 3, // Bumped version for file_mod_time migration
|
||||
version: 4, // Bumped version for bitrate column
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
@@ -142,6 +146,7 @@ class LibraryDatabase {
|
||||
release_date TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bitrate INTEGER,
|
||||
genre TEXT,
|
||||
format TEXT
|
||||
)
|
||||
@@ -169,6 +174,12 @@ class LibraryDatabase {
|
||||
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
|
||||
_log.i('Added file_mod_time column for incremental scanning');
|
||||
}
|
||||
|
||||
if (oldVersion < 4) {
|
||||
// Add bitrate column for lossy format quality info
|
||||
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
||||
_log.i('Added bitrate column for lossy format quality');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
@@ -189,6 +200,7 @@ class LibraryDatabase {
|
||||
'release_date': json['releaseDate'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'bitrate': json['bitrate'],
|
||||
'genre': json['genre'],
|
||||
'format': json['format'],
|
||||
};
|
||||
@@ -212,6 +224,7 @@ class LibraryDatabase {
|
||||
'releaseDate': row['release_date'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'bitrate': row['bitrate'],
|
||||
'genre': row['genre'],
|
||||
'format': row['format'],
|
||||
};
|
||||
|
||||
@@ -103,8 +103,6 @@ class PlatformBridge {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
@@ -509,6 +507,7 @@ class PlatformBridge {
|
||||
return {
|
||||
'genre': data['genre'] as String? ?? '',
|
||||
'label': data['label'] as String? ?? '',
|
||||
'copyright': data['copyright'] as String? ?? '',
|
||||
};
|
||||
} catch (e) {
|
||||
_log.w('Failed to get Deezer extended metadata for $trackId: $e');
|
||||
@@ -719,8 +718,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
static Future<void> cleanupExtensions() async {
|
||||
_log.d('cleanupExtensions');
|
||||
await _channel.invokeMethod('cleanupExtensions');
|
||||
@@ -1130,5 +1127,4 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
// ==================== YOUTUBE / COBALT ====================
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user