feat(library): add lyrics filtering and metadata actions

This commit is contained in:
zarzet
2026-08-27 01:44:06 +07:00
parent 04c42bd511
commit 1a7c20111a
60 changed files with 1454 additions and 276 deletions
+42
View File
@@ -8169,6 +8169,48 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'About & support'**
String get settingsGroupHelp;
/// Filter option for tracks without embedded or sidecar lyrics
///
/// In en, this message translates to:
/// **'Missing lyrics'**
String get libraryFilterMetadataMissingLyrics;
/// Track menu action that copies the track title
///
/// In en, this message translates to:
/// **'Copy track name'**
String get trackOptionCopyTrackName;
/// Track menu action that copies the artist name
///
/// In en, this message translates to:
/// **'Copy artist'**
String get trackOptionCopyArtist;
/// Track menu action that copies the track title and artist
///
/// In en, this message translates to:
/// **'Copy track and artist'**
String get trackOptionCopyTrackAndArtist;
/// Metadata menu action that copies only the selected value
///
/// In en, this message translates to:
/// **'Copy value'**
String get metadataCopyValue;
/// Metadata menu action that copies the selected key and value
///
/// In en, this message translates to:
/// **'Copy field and value'**
String get metadataCopyField;
/// Metadata menu action that copies every visible key and value
///
/// In en, this message translates to:
/// **'Copy all metadata'**
String get metadataCopyAll;
}
class _AppLocalizationsDelegate
+21
View File
@@ -5020,4 +5020,25 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsGroupHelp => 'Info & Unterstützung';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+21
View File
@@ -4975,4 +4975,25 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsGroupHelp => 'About & support';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+21
View File
@@ -4970,6 +4970,27 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settingsGroupHelp => 'Información y soporte';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+21
View File
@@ -5090,4 +5090,25 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settingsGroupHelp => 'À propos et assistance';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+21
View File
@@ -4973,4 +4973,25 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get settingsGroupHelp => 'Tentang & dukungan';
@override
String get libraryFilterMetadataMissingLyrics => 'Lirik tidak tersedia';
@override
String get trackOptionCopyTrackName => 'Salin judul lagu';
@override
String get trackOptionCopyArtist => 'Salin artis';
@override
String get trackOptionCopyTrackAndArtist => 'Salin judul dan artis';
@override
String get metadataCopyValue => 'Salin nilai';
@override
String get metadataCopyField => 'Salin field dan nilai';
@override
String get metadataCopyAll => 'Salin semua metadata';
}
+21
View File
@@ -4963,4 +4963,25 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settingsGroupHelp => 'アプリ情報とサポート';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+21
View File
@@ -4844,4 +4844,25 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settingsGroupHelp => '정보 및 지원';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+21
View File
@@ -4969,6 +4969,27 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsGroupHelp => 'Sobre e suporte';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+21
View File
@@ -5006,4 +5006,25 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settingsGroupHelp => 'О приложении и поддержка';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+21
View File
@@ -5005,4 +5005,25 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get settingsGroupHelp => 'Hakkında ve destek';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+21
View File
@@ -5023,4 +5023,25 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settingsGroupHelp => 'Про програму та підтримка';
@override
String get libraryFilterMetadataMissingLyrics => 'Missing lyrics';
@override
String get trackOptionCopyTrackName => 'Copy track name';
@override
String get trackOptionCopyArtist => 'Copy artist';
@override
String get trackOptionCopyTrackAndArtist => 'Copy track and artist';
@override
String get metadataCopyValue => 'Copy value';
@override
String get metadataCopyField => 'Copy field and value';
@override
String get metadataCopyAll => 'Copy all metadata';
}
+28
View File
@@ -6436,5 +6436,33 @@
"settingsGroupHelp": "About & support",
"@settingsGroupHelp": {
"description": "Settings group covering the about page and donations"
},
"libraryFilterMetadataMissingLyrics": "Missing lyrics",
"@libraryFilterMetadataMissingLyrics": {
"description": "Filter option for tracks without embedded or sidecar lyrics"
},
"trackOptionCopyTrackName": "Copy track name",
"@trackOptionCopyTrackName": {
"description": "Track menu action that copies the track title"
},
"trackOptionCopyArtist": "Copy artist",
"@trackOptionCopyArtist": {
"description": "Track menu action that copies the artist name"
},
"trackOptionCopyTrackAndArtist": "Copy track and artist",
"@trackOptionCopyTrackAndArtist": {
"description": "Track menu action that copies the track title and artist"
},
"metadataCopyValue": "Copy value",
"@metadataCopyValue": {
"description": "Metadata menu action that copies only the selected value"
},
"metadataCopyField": "Copy field and value",
"@metadataCopyField": {
"description": "Metadata menu action that copies the selected key and value"
},
"metadataCopyAll": "Copy all metadata",
"@metadataCopyAll": {
"description": "Metadata menu action that copies every visible key and value"
}
}
+8 -1
View File
@@ -6145,5 +6145,12 @@
"settingsGroupContent": "Konten & metadata",
"settingsGroupDownloads": "Unduhan & file",
"settingsGroupSystem": "Sistem",
"settingsGroupHelp": "Tentang & dukungan"
"settingsGroupHelp": "Tentang & dukungan",
"libraryFilterMetadataMissingLyrics": "Lirik tidak tersedia",
"trackOptionCopyTrackName": "Salin judul lagu",
"trackOptionCopyArtist": "Salin artis",
"trackOptionCopyTrackAndArtist": "Salin judul dan artis",
"metadataCopyValue": "Salin nilai",
"metadataCopyField": "Salin field dan nilai",
"metadataCopyAll": "Salin semua metadata"
}
@@ -33,6 +33,8 @@ class DownloadHistoryItem {
final String? label;
final String? copyright;
final bool explicit;
final bool hasLyrics;
final int lyricsMetadataScanVersion;
const DownloadHistoryItem({
required this.id,
@@ -67,6 +69,8 @@ class DownloadHistoryItem {
this.label,
this.copyright,
this.explicit = false,
this.hasLyrics = false,
this.lyricsMetadataScanVersion = 0,
});
Map<String, dynamic> toJson() => {
@@ -102,6 +106,8 @@ class DownloadHistoryItem {
'label': label,
'copyright': copyright,
'explicit': explicit,
'hasLyrics': hasLyrics,
'lyricsMetadataScanVersion': lyricsMetadataScanVersion,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
@@ -138,6 +144,9 @@ class DownloadHistoryItem {
label: json['label'] as String?,
copyright: json['copyright'] as String?,
explicit: parseExplicitFlag(json['explicit']) == true,
hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1,
lyricsMetadataScanVersion:
(json['lyricsMetadataScanVersion'] as num?)?.toInt() ?? 0,
);
DownloadHistoryItem copyWith({
@@ -170,6 +179,8 @@ class DownloadHistoryItem {
String? label,
String? copyright,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) {
return DownloadHistoryItem(
id: id,
@@ -204,6 +215,9 @@ class DownloadHistoryItem {
label: label ?? this.label,
copyright: copyright ?? this.copyright,
explicit: explicit ?? this.explicit,
hasLyrics: hasLyrics ?? this.hasLyrics,
lyricsMetadataScanVersion:
lyricsMetadataScanVersion ?? this.lyricsMetadataScanVersion,
);
}
}
+22 -1
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
part 'download_history_models.dart';
@@ -202,6 +203,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
normalizeOptionalString(item.copyright) ??
normalizeOptionalString(existing.copyright),
explicit: item.explicit || existing.explicit,
hasLyrics:
item.lyricsMetadataScanVersion >=
existing.lyricsMetadataScanVersion
? item.hasLyrics
: existing.hasLyrics,
lyricsMetadataScanVersion: max(
item.lyricsMetadataScanVersion,
existing.lyricsMetadataScanVersion,
),
);
return (item: mergedItem, existingId: existing?.id);
}
@@ -479,6 +489,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
int? duration,
String? composer,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
final target = await _historyItemForUpdate(id);
if (target == null) {
@@ -502,6 +514,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
duration: duration,
composer: composer,
explicit: explicit,
hasLyrics: hasLyrics,
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
);
if (updated.quality == current.quality &&
@@ -515,7 +529,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updated.totalDiscs == current.totalDiscs &&
updated.duration == current.duration &&
updated.composer == current.composer &&
updated.explicit == current.explicit) {
updated.explicit == current.explicit &&
updated.hasLyrics == current.hasLyrics &&
updated.lyricsMetadataScanVersion ==
current.lyricsMetadataScanVersion) {
return;
}
@@ -550,6 +567,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
String? label,
String? copyright,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
final target = await _historyItemForUpdate(id);
if (target == null) {
@@ -574,6 +593,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
label: label,
copyright: copyright,
explicit: explicit,
hasLyrics: hasLyrics,
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
);
final updatedItems = target.index >= 0
@@ -260,11 +260,19 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
trimmed.endsWith('.aac') ||
trimmed.endsWith('.mp3') ||
trimmed.endsWith('.opus') ||
trimmed.endsWith('.ogg');
trimmed.endsWith('.ogg') ||
trimmed.endsWith('.ape') ||
trimmed.endsWith('.wv') ||
trimmed.endsWith('.mpc') ||
trimmed.endsWith('.wav') ||
trimmed.endsWith('.aiff') ||
trimmed.endsWith('.aif') ||
trimmed.endsWith('.aifc');
}
bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) {
return _needsAverageBitrateBackfill(item) ||
return item.lyricsMetadataScanVersion < 1 ||
_needsAverageBitrateBackfill(item) ||
_shouldBackfillAudioMetadataIgnoringBitrate(item);
}
@@ -372,10 +380,54 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
);
}
Future<bool?> _probeSidecarLyrics(DownloadHistoryItem item) async {
final filePath = item.filePath.trim();
if (filePath.isEmpty) return null;
String? tempPath;
try {
if (filePath.startsWith('content://')) {
final treeUri = normalizeOptionalString(item.downloadTreeUri);
final fileName = normalizeOptionalString(item.safFileName);
if (treeUri == null || fileName == null) return null;
final replacedName = fileName.replaceFirst(RegExp(r'\.[^.]+$'), '.lrc');
final lrcName = replacedName == fileName
? '$fileName.lrc'
: replacedName;
final resolved = await PlatformBridge.resolveSafFile(
treeUri: treeUri,
relativeDir: item.safRelativeDir ?? '',
fileName: lrcName,
);
final uri = normalizeOptionalString(resolved['uri']?.toString());
if (uri == null) return false;
tempPath = await PlatformBridge.copyContentUriToTemp(uri);
if (tempPath == null) return null;
return hasUsableLyricsContent(await File(tempPath).readAsString());
}
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
final sidecar = File(safeLrcPath);
if (!await sidecar.exists()) return false;
return hasUsableLyricsContent(await sidecar.readAsString());
} catch (e) {
_historyLog.d('Sidecar lyrics probe failed for $filePath: $e');
return null;
} finally {
if (tempPath != null) {
try {
await File(tempPath).delete();
} catch (_) {}
}
}
}
Future<Map<String, dynamic>?> _probeAudioMetadata(
String filePath, {
DownloadHistoryItem item, {
String? fallbackQuality,
}) async {
final filePath = item.filePath;
if (!_supportsAudioMetadataProbe(filePath)) {
return null;
}
@@ -414,6 +466,13 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
final totalTracks = readPositiveInt(result['total_tracks']);
final discNumber = readPositiveInt(result['disc_number']);
final totalDiscs = readPositiveInt(result['total_discs']);
final embeddedHasLyrics =
result['hasLyrics'] == true ||
hasUsableLyricsContent(result['lyrics']?.toString() ?? '');
final sidecarHasLyrics = await _probeSidecarLyrics(item);
final hasLyrics = embeddedHasLyrics || sidecarHasLyrics == true;
final lyricsMetadataScanVersion =
embeddedHasLyrics || sidecarHasLyrics != null ? 1 : 0;
if (quality == null &&
bitDepth == null &&
@@ -425,7 +484,10 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
trackNumber == null &&
totalTracks == null &&
discNumber == null &&
totalDiscs == null) {
totalDiscs == null &&
result['hasLyrics'] == null &&
result['lyrics'] == null &&
sidecarHasLyrics == null) {
return null;
}
@@ -442,6 +504,8 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
'totalTracks': totalTracks,
'discNumber': discNumber,
'totalDiscs': totalDiscs,
'hasLyrics': hasLyrics,
'lyricsMetadataScanVersion': lyricsMetadataScanVersion,
};
} catch (e) {
_historyLog.d('Audio metadata probe failed for $filePath: $e');
@@ -497,9 +561,10 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
final item = items[index];
Map<String, dynamic>? probed;
if (_shouldBackfillAudioMetadataIgnoringBitrate(item)) {
if (item.lyricsMetadataScanVersion < 1 ||
_shouldBackfillAudioMetadataIgnoringBitrate(item)) {
probed = await _probeAudioMetadata(
item.filePath,
item,
fallbackQuality: item.quality,
);
} else if (_needsAverageBitrateBackfill(item)) {
@@ -541,6 +606,9 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
final resolvedTotalTracks = probed['totalTracks'] as int?;
final resolvedDiscNumber = probed['discNumber'] as int?;
final resolvedTotalDiscs = probed['totalDiscs'] as int?;
final resolvedHasLyrics = probed['hasLyrics'] as bool?;
final resolvedLyricsScanVersion =
probed['lyricsMetadataScanVersion'] as int?;
final qualityChanged =
resolvedQuality != null && resolvedQuality != item.quality;
@@ -566,6 +634,11 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
resolvedDiscNumber != null && resolvedDiscNumber != item.discNumber;
final totalDiscsChanged =
resolvedTotalDiscs != null && resolvedTotalDiscs != item.totalDiscs;
final hasLyricsChanged =
resolvedHasLyrics != null && resolvedHasLyrics != item.hasLyrics;
final lyricsScanVersionChanged =
resolvedLyricsScanVersion != null &&
resolvedLyricsScanVersion != item.lyricsMetadataScanVersion;
if (!qualityChanged &&
!bitDepthChanged &&
@@ -577,7 +650,9 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
!trackNumberChanged &&
!totalTracksChanged &&
!discNumberChanged &&
!totalDiscsChanged) {
!totalDiscsChanged &&
!hasLyricsChanged &&
!lyricsScanVersionChanged) {
continue;
}
@@ -593,6 +668,8 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
totalTracks: resolvedTotalTracks,
discNumber: resolvedDiscNumber,
totalDiscs: resolvedTotalDiscs,
hasLyrics: resolvedHasLyrics,
lyricsMetadataScanVersion: resolvedLyricsScanVersion,
);
updatedItems ??= [...items];
updatedItems[index] = updated;
@@ -31,6 +31,7 @@ import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
@@ -750,10 +750,11 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
filePath: '',
durationMs: track.duration * 1000,
);
if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') {
if (hasUsableLyricsContent(fetchedLrc) &&
!isInstrumentalLyricsMarker(fetchedLrc)) {
lrcContent = fetchedLrc;
_log.d('Lyrics fetched for $format (${fetchedLrc.length} chars)');
} else if (fetchedLrc == '[instrumental:true]') {
} else if (isInstrumentalLyricsMarker(fetchedLrc)) {
_log.d('Track is instrumental, skipping lyrics handling');
}
} catch (e) {
@@ -259,6 +259,8 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
String? genre,
String? label,
String? copyright,
required bool hasLyrics,
required int lyricsMetadataScanVersion,
}) {
final backendTitle = result['title'] as String?;
final backendArtist = result['artist'] as String?;
@@ -343,6 +345,53 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
explicit:
trackToDownload.isExplicit ||
parseExplicitFlag(result['explicit']) == true,
hasLyrics: hasLyrics,
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
);
}
Future<({bool hasLyrics, int scanVersion})> _resolveFinalLyricsAvailability({
required String filePath,
Map<String, dynamic>? probedMetadata,
bool externalLrcWritten = false,
}) async {
var metadataScanned = false;
var hasEmbeddedLyrics = false;
try {
final metadata =
probedMetadata ?? await PlatformBridge.readFileMetadata(filePath);
if (metadata['error'] == null &&
(metadata.containsKey('lyrics') ||
metadata.containsKey('hasLyrics'))) {
metadataScanned = true;
hasEmbeddedLyrics =
metadata['hasLyrics'] == true ||
hasUsableLyricsContent(metadata['lyrics']?.toString() ?? '');
}
} catch (e) {
_log.d('Final lyrics metadata probe failed for $filePath: $e');
}
var hasSidecarLyrics = externalLrcWritten;
if (!isContentUri(filePath)) {
try {
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
final sidecar = File(safeLrcPath);
if (await sidecar.exists()) {
hasSidecarLyrics = hasUsableLyricsContent(
await sidecar.readAsString(),
);
}
} catch (e) {
_log.d('Final sidecar lyrics probe failed for $filePath: $e');
}
}
final hasLyrics = hasEmbeddedLyrics || hasSidecarLyrics;
return (
hasLyrics: hasLyrics,
scanVersion: hasLyrics || metadataScanned ? 1 : 0,
);
}
@@ -462,14 +511,14 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
}
}
Future<void> _writeLrcToSaf({
Future<bool> _writeLrcToSaf({
required String treeUri,
required String relativeDir,
required String baseName,
required String lrcContent,
}) async {
try {
if (lrcContent.isEmpty) return;
if (!hasUsableLyricsContent(lrcContent)) return false;
final tempDir = await getTemporaryDirectory();
final tempPath = '${tempDir.path}/$baseName.lrc';
await File(tempPath).writeAsString(lrcContent);
@@ -489,8 +538,10 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
try {
await File(tempPath).delete();
} catch (_) {}
return uri != null;
} catch (e) {
_log.w('Failed to create external LRC in SAF: $e');
return false;
}
}
@@ -1230,7 +1281,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
/// modes). [resolveBaseName] and [onFetchError] are each caller's own
/// base-name fallback chain and fetch-failure log line, evaluated lazily
/// to match the original call sites exactly.
Future<void> _saveExternalLrc({
Future<bool> _saveExternalLrc({
required Map<String, dynamic> result,
required AppSettings settings,
required ExtensionState extensionState,
@@ -1250,11 +1301,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
!_shouldSkipLyrics(extensionState, track.source, service) &&
(lyricsMode == 'external' || lyricsMode == 'both');
if (!shouldSaveExternalLrc) {
return;
return false;
}
String? lrcContent = result['lyrics_lrc'] as String?;
if (lrcContent == null || lrcContent.isEmpty) {
if (!hasUsableLyricsContent(lrcContent ?? '')) {
try {
lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
@@ -1266,31 +1317,37 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
onFetchError(e);
}
}
if (lrcContent == null || lrcContent.isEmpty) {
return;
if (!hasUsableLyricsContent(lrcContent ?? '') ||
isInstrumentalLyricsMarker(lrcContent!)) {
return false;
}
final resolvedLrc = lrcContent;
if (storageMode == 'saf' && isContentUri(filePath)) {
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
return;
return false;
}
final baseName = await resolveBaseName();
await _writeLrcToSaf(
final written = await _writeLrcToSaf(
treeUri: downloadTreeUri,
relativeDir: safRelativeDir,
baseName: baseName,
lrcContent: lrcContent,
lrcContent: resolvedLrc,
);
return;
if (written) result['lyrics_lrc'] = resolvedLrc;
return written;
}
try {
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
await File(safeLrcPath).writeAsString(lrcContent);
await File(safeLrcPath).writeAsString(resolvedLrc);
result['lyrics_lrc'] = resolvedLrc;
_log.d('Native-worker external LRC saved: $safeLrcPath');
return true;
} catch (e) {
_log.w('Failed to save native-worker external LRC: $e');
return false;
}
}
}
@@ -149,6 +149,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
storedQuality;
final useSaf = context.storageMode == 'saf';
final resultFileName = result['file_name']?.toString().trim();
final lyricsAvailability = await _resolveFinalLyricsAvailability(
filePath: filePath,
);
await ref
.read(downloadHistoryProvider.notifier)
@@ -172,6 +175,8 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
genre: normalizeOptionalString(result['genre']?.toString()),
label: normalizeOptionalString(result['label']?.toString()),
copyright: normalizeOptionalString(result['copyright']?.toString()),
hasLyrics: lyricsAvailability.hasLyrics,
lyricsMetadataScanVersion: lyricsAvailability.scanVersion,
),
preserveTrackVariant: context.item.preserveQualityVariant,
);
@@ -1441,7 +1446,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (finalQuality != null) actualQuality = finalQuality;
}
await _saveExternalLrc(
final externalLrcWritten = await _saveExternalLrc(
result: result,
settings: settings,
extensionState: ref.read(extensionProvider),
@@ -1483,6 +1488,10 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
lowerFilePath.endsWith('.ogg');
final completedFilePath = filePath;
final lyricsAvailability = await _resolveFinalLyricsAvailability(
filePath: completedFilePath,
externalLrcWritten: externalLrcWritten,
);
await persistBeforePublishingDownloadCompletion(
persist: () async {
if (!settings.saveDownloadHistory) return;
@@ -1511,6 +1520,8 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
copyright: normalizeOptionalString(
result['copyright'] as String?,
),
hasLyrics: lyricsAvailability.hasLyrics,
lyricsMetadataScanVersion: lyricsAvailability.scanVersion,
),
preserveTrackVariant: item.preserveQualityVariant,
);
@@ -128,6 +128,7 @@ class _DownloadRun {
/// Filled by the SAF embed op from the local temp so the final quality
/// probe doesn't have to copy the published file back out of SAF.
Map<String, dynamic>? probedFinalMetadata;
bool externalLrcWritten = false;
Future<void> _run() async {
final normalizedService = n._normalizeQueuedService(item.service);
@@ -802,7 +803,7 @@ class _DownloadRun {
final lrcTarget = filePath;
if (effectiveSafMode && lrcTarget != null && isContentUri(lrcTarget)) {
await n._saveExternalLrc(
externalLrcWritten = await n._saveExternalLrc(
result: result,
settings: settings,
extensionState: extensionState,
@@ -1609,7 +1610,7 @@ class _DownloadRun {
required String format,
bool writeExternalLrc = true,
bool rebuildTrack = true,
}) {
}) async {
final track = rebuildTrack
? n._buildTrackForMetadataEmbedding(
trackToDownload,
@@ -1617,7 +1618,7 @@ class _DownloadRun {
resolvedAlbumArtist,
)
: trackToDownload;
return n._embedMetadataToFile(
final lrcContent = await n._embedMetadataToFile(
path,
track,
format: format,
@@ -1628,6 +1629,10 @@ class _DownloadRun {
downloadService: item.service,
writeExternalLrc: writeExternalLrc,
);
if (lrcContent != null && lrcContent.isNotEmpty) {
result['lyrics_lrc'] = lrcContent;
}
return lrcContent;
}
Future<void> _recoverSafUriIfNeeded() async {
@@ -1735,6 +1740,7 @@ class _DownloadRun {
? probed
: await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) {
probedFinalMetadata = metadata;
final probedBitDepth = metadata['bit_depth'] is num
? (metadata['bit_depth'] as num).toInt()
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
@@ -1790,6 +1796,11 @@ class _DownloadRun {
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
final historySampleRate = isLossyOutput ? null : finalSampleRate;
final historyBitrate = finalBitrateKbps;
final lyricsAvailability = await n._resolveFinalLyricsAvailability(
filePath: historyFilePath,
probedMetadata: probedFinalMetadata,
externalLrcWritten: externalLrcWritten,
);
await persistBeforePublishingDownloadCompletion(
persist: () async {
@@ -1814,6 +1825,8 @@ class _DownloadRun {
genre: effectiveGenre,
label: effectiveLabel,
copyright: effectiveCopyright,
hasLyrics: lyricsAvailability.hasLyrics,
lyricsMetadataScanVersion: lyricsAvailability.scanVersion,
),
preserveTrackVariant: item.preserveQualityVariant,
);
+24 -15
View File
@@ -30,6 +30,7 @@ import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
class _AlbumCache {
static final _cache = TtlCache<List<Track>>(
@@ -437,21 +438,29 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
blurAndScrimBackground: showSquareCover,
coverBuilder: showSquareCover
? (context, coverSize) => coverThumbUrl != null
? CachedNetworkImage(
imageUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surfaceContainerHighest),
errorWidget: (_, _, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 48,
color: colorScheme.onSurfaceVariant,
? DownloadableCover(
coverUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl,
baseName: [
if (artistName != null && artistName.isNotEmpty)
artistName,
widget.albumName,
].join(' - '),
child: CachedNetworkImage(
imageUrl: highResCoverUrl(coverThumbUrl) ?? coverThumbUrl,
fit: BoxFit.cover,
width: coverSize,
height: coverSize,
memCacheWidth: cacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surfaceContainerHighest),
errorWidget: (_, _, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
)
+1
View File
@@ -35,6 +35,7 @@ import 'package:spotiflac_android/widgets/cached_cover_image.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/cross_extension_share_sheet.dart';
import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
part 'artist_screen_widgets.dart';
+85 -68
View File
@@ -110,20 +110,24 @@ extension _ArtistScreenSections on _ArtistScreenState {
MotionHeaderBanner(
videoUrl: headerVideoUrl!,
fallback: hasValidImage
? CachedCoverImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
memCacheWidth: 800,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
? DownloadableCover(
coverUrl: imageUrl,
baseName: widget.artistName,
child: CachedCoverImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
memCacheWidth: 800,
placeholder: (context, url) => Container(
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
)
@@ -137,19 +141,23 @@ extension _ArtistScreenSections on _ArtistScreenState {
),
)
else if (hasValidImage)
CachedCoverImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
memCacheWidth: 800,
placeholder: (context, url) =>
Container(color: colorScheme.surfaceContainerHighest),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
DownloadableCover(
coverUrl: imageUrl,
baseName: widget.artistName,
child: CachedCoverImage(
imageUrl: imageUrl!,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
memCacheWidth: 800,
placeholder: (context, url) =>
Container(color: colorScheme.surfaceContainerHighest),
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.person,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
),
)
@@ -448,26 +456,30 @@ extension _ArtistScreenSections on _ArtistScreenState {
),
const SizedBox(width: 12),
track.coverUrl != null
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(4),
placeholder: (context, url) => ShimmerLoading(
child: Container(
? DownloadableCover(
coverUrl: track.coverUrl,
baseName: '${track.artistName} - ${track.name}',
child: CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(4),
placeholder: (context, url) => ShimmerLoading(
child: Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
),
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
),
),
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 24,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
),
)
@@ -682,22 +694,35 @@ extension _ArtistScreenSections on _ArtistScreenState {
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedCoverImage(
imageUrl: album.coverUrl!,
width: tileSize,
height: tileSize,
fit: BoxFit.cover,
memCacheWidth: (tileSize * 2).round(),
memCacheHeight: (tileSize * 2).round(),
placeholder: (context, url) => ShimmerLoading(
child: Container(
color: colorScheme.surfaceContainerHighest,
DownloadableCover(
coverUrl: album.coverUrl,
baseName: '${album.artists} - ${album.name}',
enabled: !isSelectionMode,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: album.coverUrl != null
? CachedCoverImage(
imageUrl: album.coverUrl!,
width: tileSize,
height: tileSize,
fit: BoxFit.cover,
memCacheWidth: (tileSize * 2).round(),
memCacheHeight: (tileSize * 2).round(),
placeholder: (context, url) => ShimmerLoading(
child: Container(
color: colorScheme.surfaceContainerHighest,
),
),
),
errorWidget: (context, url, error) => Container(
errorWidget: (context, url, error) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 40,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
@@ -705,15 +730,7 @@ extension _ArtistScreenSections on _ArtistScreenState {
size: 40,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
color: colorScheme.onSurfaceVariant,
size: 40,
),
),
),
),
if (isSelectionMode)
Positioned.fill(
+14 -9
View File
@@ -42,6 +42,7 @@ import 'package:spotiflac_android/widgets/in_library_badge.dart';
import 'package:spotiflac_android/widgets/preview_button.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart';
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
part 'home_tab_helpers.dart';
part 'home_tab_explore.dart';
@@ -1214,15 +1215,19 @@ class _HomeTabState extends ConsumerState<HomeTab>
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
_DownloadedOrRemoteCover(
downloadedFilePath: item.filePath,
imageUrl: item.coverUrl,
width: coverSize,
height: coverSize,
borderRadius: BorderRadius.circular(12),
fallbackIcon: Icons.music_note,
fallbackIconSize: 32,
colorScheme: colorScheme,
DownloadableCover(
coverUrl: item.coverUrl,
baseName: '${item.artistName} - ${item.trackName}',
child: _DownloadedOrRemoteCover(
downloadedFilePath: item.filePath,
imageUrl: item.coverUrl,
width: coverSize,
height: coverSize,
borderRadius: BorderRadius.circular(12),
fallbackIcon: Icons.music_note,
fallbackIconSize: 32,
colorScheme: colorScheme,
),
),
const SizedBox(height: 6),
Text(
+52 -42
View File
@@ -174,24 +174,40 @@ extension _HomeTabExploreUI on _HomeTabState {
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 10,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
placeholder: (context, url) => ShimmerLoading(
child: Container(
DownloadableCover(
coverUrl: item.coverUrl,
baseName: item.artists.isEmpty
? item.name
: '${item.artists} - ${item.name}',
child: ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 10,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
placeholder: (context, url) => ShimmerLoading(
child: Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
),
),
errorWidget: (context, url, error) => Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
),
),
errorWidget: (context, url, error) => Container(
)
: Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
@@ -201,17 +217,7 @@ extension _HomeTabExploreUI on _HomeTabState {
size: iconSize,
),
),
)
: Container(
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
),
),
),
const SizedBox(height: 8),
Text(
@@ -363,24 +369,28 @@ extension _HomeTabExploreUI on _HomeTabState {
padding: const EdgeInsets.all(16),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 64,
height: 64,
fit: BoxFit.cover,
)
: Container(
width: 64,
height: 64,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
DownloadableCover(
coverUrl: item.coverUrl,
baseName: '${item.artists} - ${item.name}',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 64,
height: 64,
fit: BoxFit.cover,
)
: Container(
width: 64,
height: 64,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
const SizedBox(width: 16),
Expanded(
+17 -11
View File
@@ -144,18 +144,24 @@ extension _HomeTabRecentUI on _HomeTabState {
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
_DownloadedOrRemoteCover(
downloadedFilePath: isDownloaded
? downloadFilePathByRecentKey['${item.type.name}:${item.id}']
: null,
imageUrl: item.imageUrl,
width: 56,
height: 56,
borderRadius: BorderRadius.circular(
item.type == RecentAccessType.artist ? 28 : 4,
DownloadableCover(
coverUrl: item.imageUrl,
baseName: item.subtitle == null
? item.name
: '${item.subtitle} - ${item.name}',
child: _DownloadedOrRemoteCover(
downloadedFilePath: isDownloaded
? downloadFilePathByRecentKey['${item.type.name}:${item.id}']
: null,
imageUrl: item.imageUrl,
width: 56,
height: 56,
borderRadius: BorderRadius.circular(
item.type == RecentAccessType.artist ? 28 : 4,
),
fallbackIcon: typeIcon,
colorScheme: colorScheme,
),
fallbackIcon: typeIcon,
colorScheme: colorScheme,
),
const SizedBox(width: 12),
Expanded(
+24 -20
View File
@@ -1497,15 +1497,29 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
errorWidget: (context, url, error) => Container(
DownloadableCover(
coverUrl: item.coverUrl,
baseName: '${item.artists} - ${item.name}',
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedCoverImage(
imageUrl: item.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
errorWidget: (context, url, error) => Container(
width: 48,
height: 48,
color: widget.colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: widget.colorScheme.onSurfaceVariant,
size: 24,
),
),
)
: Container(
width: 48,
height: 48,
color: widget.colorScheme.surfaceContainerHighest,
@@ -1515,17 +1529,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> {
size: 24,
),
),
)
: Container(
width: 48,
height: 48,
color: widget.colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: widget.colorScheme.onSurfaceVariant,
size: 24,
),
),
),
),
const SizedBox(width: 12),
Expanded(
+2 -1
View File
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/isrc_utils.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -2031,7 +2032,7 @@ class _MetadataList extends StatelessWidget {
(l10n.editMetadataFieldDate, s(meta['date'])),
(l10n.editMetadataFieldTrackNum, s(meta['track_number'])),
(l10n.editMetadataFieldDiscNum, s(meta['disc_number'])),
(l10n.editMetadataFieldIsrc, s(meta['isrc'])),
(l10n.editMetadataFieldIsrc, formatIsrcForDisplay(s(meta['isrc']))),
(l10n.editMetadataFieldLabel, s(meta['label'])),
(l10n.editMetadataFieldCopyright, s(meta['copyright'])),
(l10n.libraryFilterFormat, s(meta['format']).toUpperCase()),
+21 -11
View File
@@ -26,6 +26,7 @@ import 'package:spotiflac_android/widgets/track_list_tile.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
import 'package:spotiflac_android/widgets/error_card.dart';
import 'package:spotiflac_android/widgets/downloadable_cover.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
@@ -319,12 +320,16 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
coverBuilder: hasMotion
? null
: (context, coverSize) => _coverUrl != null
? CachedCoverImage(
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) => playlistPlaceholder(),
errorWidget: (_, _, _) => playlistPlaceholder(size: 48),
? DownloadableCover(
coverUrl: highResCoverUrl(_coverUrl) ?? _coverUrl,
baseName: _playlistName,
child: CachedCoverImage(
imageUrl: highResCoverUrl(_coverUrl) ?? _coverUrl!,
fit: BoxFit.cover,
memCacheWidth: cacheWidth,
placeholder: (_, _) => playlistPlaceholder(),
errorWidget: (_, _, _) => playlistPlaceholder(size: 48),
),
)
: playlistPlaceholder(size: 48),
meta: HeaderMetaRow(
@@ -433,11 +438,16 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen>
forceQualityPicker: forceQualityPicker,
),
leading: track.coverUrl != null
? CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
? DownloadableCover(
coverUrl: track.coverUrl,
baseName: '${track.artistName} - ${track.name}',
enabled: !isSelectionMode,
child: CachedCoverImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
borderRadius: BorderRadius.circular(8),
),
)
: Container(
width: 48,
+13
View File
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/ordered_range_selection.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -233,6 +234,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
String? _selectionAnchorId;
final SelectionOverlayController _selectionOverlay =
SelectionOverlayController();
List<UnifiedLibraryItem> _selectionOverlayItems = const [];
@@ -1057,6 +1059,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
() => tempMetadata = 'missing-label',
),
),
FilterChip(
label: Text(
context
.l10n
.libraryFilterMetadataMissingLyrics,
),
selected: tempMetadata == 'missing-lyrics',
onSelected: (_) => setSheetState(
() => tempMetadata = 'missing-lyrics',
),
),
],
),
const SizedBox(height: 16),
+1
View File
@@ -178,6 +178,7 @@ extension _QueueTabBatchActions on _QueueTabState {
_setState(() {
_selectedIds.clear();
_isSelectionMode = false;
_selectionAnchorId = null;
});
}
+4 -2
View File
@@ -883,7 +883,7 @@ extension _QueueTabItemWidgets on _QueueTabState {
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
),
onLongPress: _isSelectionMode
? null
? () => _selectRangeTo(item.id, libraryItems)
: () => _enterSelectionMode(item.id),
leading: Hero(
tag: 'cover_lib_${item.id}',
@@ -1022,7 +1022,9 @@ extension _QueueTabItemWidgets on _QueueTabState {
album: item.albumName,
coverUrl: item.coverUrl ?? item.localCoverPath ?? '',
),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(item.id),
onLongPress: _isSelectionMode
? () => _selectRangeTo(item.id, libraryItems)
: () => _enterSelectionMode(item.id),
cover: Hero(
tag: 'cover_lib_${item.id}',
child: _buildUnifiedCoverImage(item, colorScheme),
+26 -8
View File
@@ -8,6 +8,7 @@ extension _QueueTabSelectionActions on _QueueTabState {
_selectedPlaylistIds.clear();
_isSelectionMode = true;
_selectedIds.add(itemId);
_selectionAnchorId = itemId;
});
_hidePlaylistSelectionOverlay();
}
@@ -16,6 +17,7 @@ extension _QueueTabSelectionActions on _QueueTabState {
_setState(() {
_isSelectionMode = false;
_selectedIds.clear();
_selectionAnchorId = null;
});
_hideSelectionOverlay();
}
@@ -23,14 +25,13 @@ extension _QueueTabSelectionActions on _QueueTabState {
void _toggleSelection(String itemId) {
var shouldHideOverlay = false;
_setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
shouldHideOverlay = true;
}
} else {
_selectedIds.add(itemId);
_selectionAnchorId = toggleOrderedSelection(
selected: _selectedIds,
target: itemId,
);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
shouldHideOverlay = true;
}
});
if (shouldHideOverlay) {
@@ -41,6 +42,22 @@ extension _QueueTabSelectionActions on _QueueTabState {
void _selectAll(List<UnifiedLibraryItem> items) {
_setState(() {
_selectedIds.addAll(items.map((e) => e.id));
_selectionAnchorId = items.lastOrNull?.id;
});
}
void _selectRangeTo(String itemId, List<UnifiedLibraryItem> visibleItems) {
HapticFeedback.selectionClick();
_setState(() {
_isSelectionMode = true;
_selectionAnchorId = addOrderedSelectionRange(
selected: _selectedIds,
visibleItems: visibleItems
.map((item) => item.id)
.toList(growable: false),
target: itemId,
anchor: _selectionAnchorId,
);
});
}
@@ -107,6 +124,7 @@ extension _QueueTabSelectionActions on _QueueTabState {
_setState(() {
_isSelectionMode = false;
_selectedIds.clear();
_selectionAnchorId = null;
_isPlaylistSelectionMode = true;
_selectedPlaylistIds.add(playlistId);
});
+58 -11
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/utils/ordered_range_selection.dart';
final RegExp _domainPattern = RegExp(
r'domain:\s*([^\s,]+)',
@@ -26,6 +27,7 @@ class _LogScreenState extends State<LogScreen> {
bool _autoScroll = true;
bool _selectionMode = false;
final Set<LogEntry> _selectedEntries = <LogEntry>{};
LogEntry? _selectionAnchor;
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
@@ -52,7 +54,12 @@ class _LogScreenState extends State<LogScreen> {
_selectedEntries.removeWhere(
(entry) => !currentEntries.contains(entry),
);
if (_selectedEntries.isEmpty) _selectionMode = false;
if (_selectedEntries.isEmpty) {
_selectionMode = false;
_selectionAnchor = null;
} else if (!_selectedEntries.contains(_selectionAnchor)) {
_selectionAnchor = _selectedEntries.last;
}
}
setState(() {});
if (_autoScroll && _scrollController.hasClients) {
@@ -98,23 +105,39 @@ class _LogScreenState extends State<LogScreen> {
_selectionMode = true;
_autoScroll = false;
_selectedEntries.add(entry);
_selectionAnchor = entry;
});
}
void _toggleEntrySelection(LogEntry entry) {
if (!_selectionMode) return;
setState(() {
if (!_selectedEntries.add(entry)) {
_selectedEntries.remove(entry);
}
_selectionAnchor = toggleOrderedSelection(
selected: _selectedEntries,
target: entry,
);
if (_selectedEntries.isEmpty) _selectionMode = false;
});
}
void _selectRangeTo(LogEntry entry) {
setState(() {
_selectionMode = true;
_autoScroll = false;
_selectionAnchor = addOrderedSelectionRange(
selected: _selectedEntries,
visibleItems: _filteredLogs,
target: entry,
anchor: _selectionAnchor,
);
});
}
void _exitSelectionMode() {
setState(() {
_selectionMode = false;
_selectedEntries.clear();
_selectionAnchor = null;
});
}
@@ -129,16 +152,19 @@ class _LogScreenState extends State<LogScreen> {
_selectedEntries.addAll(visibleLogs);
}
_selectionMode = _selectedEntries.isNotEmpty;
_selectionAnchor = _selectedEntries.isEmpty
? null
: allVisibleSelected
? _selectedEntries.last
: visibleLogs.lastOrNull;
});
}
Future<void> _copySelectedLogs() async {
final selectedInLogOrder = LogBuffer().entries
.where(_selectedEntries.contains)
.toList(growable: false);
if (selectedInLogOrder.isEmpty) return;
final selectedInUserOrder = _selectedEntries.toList(growable: false);
if (selectedInUserOrder.isEmpty) return;
await Clipboard.setData(
ClipboardData(text: formatLogEntries(selectedInLogOrder)),
ClipboardData(text: formatLogEntries(selectedInUserOrder)),
);
if (!mounted) return;
_exitSelectionMode();
@@ -155,7 +181,18 @@ class _LogScreenState extends State<LogScreen> {
void _shareLogs() async {
final logs = await LogBuffer().exportWithDeviceInfo();
SharePlus.instance.share(
ShareParams(text: logs, subject: 'SpotiFLAC Logs'),
ShareParams(text: logs, subject: 'SpotiFLAC Mobile Logs'),
);
}
void _shareSelectedLogs() {
final selectedInUserOrder = _selectedEntries.toList(growable: false);
if (selectedInUserOrder.isEmpty) return;
SharePlus.instance.share(
ShareParams(
text: formatLogEntries(selectedInUserOrder),
subject: 'SpotiFLAC Mobile Logs',
),
);
}
@@ -174,6 +211,7 @@ class _LogScreenState extends State<LogScreen> {
onPressed: () {
_selectionMode = false;
_selectedEntries.clear();
_selectionAnchor = null;
LogBuffer().clear();
Navigator.pop(context);
},
@@ -249,6 +287,13 @@ class _LogScreenState extends State<LogScreen> {
? null
: _copySelectedLogs,
),
IconButton(
icon: const Icon(Icons.share),
tooltip: context.l10n.logShareLogs,
onPressed: _selectedEntries.isEmpty
? null
: _shareSelectedLogs,
),
]
: [
IconButton(
@@ -488,7 +533,9 @@ class _LogScreenState extends State<LogScreen> {
selectionMode: _selectionMode,
selected: _selectedEntries.contains(log),
onTap: () => _toggleEntrySelection(log),
onLongPress: () => _enterSelectionMode(log),
onLongPress: () => _selectionMode
? _selectRangeTo(log)
: _enterSelectionMode(log),
);
}),
],
+1 -1
View File
@@ -96,7 +96,7 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState {
_lyrics = null;
_rawLyrics = null;
_lyricsSource = context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = false;
_lyricsEmbedded = true;
} else {
_lyrics = null;
_rawLyrics = null;
+54 -2
View File
@@ -524,7 +524,12 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
_MetadataItem(context.l10n.trackAlbumType, albumType!),
if (comment != null)
_MetadataItem(context.l10n.editMetadataFieldComment, comment!),
if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!),
if (isrc != null && isrc!.isNotEmpty)
_MetadataItem(
'ISRC',
formatIsrcForDisplay(isrc),
rawValue: canonicalIsrcForCopy(isrc),
),
];
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
@@ -567,8 +572,13 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
metadata.label == 'Qobuz ID';
return InkWell(
onTap: isCopyable
? () => _copyToClipboard(context, metadata.value)
? () => _copyToClipboard(context, metadata.rawValue)
: null,
onLongPress: () => _showMetadataCopySheet(
context,
metadata: metadata,
allMetadata: items,
),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
@@ -607,6 +617,48 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
);
}
Future<void> _showMetadataCopySheet(
BuildContext context, {
required _MetadataItem metadata,
required List<_MetadataItem> allMetadata,
}) async {
final action = await showAppBottomSheet<String>(
context: context,
title: metadata.label,
subtitle: metadata.value,
maxHeightFactor: 0.58,
builder: (sheetContext) => ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 12),
children: [
ListTile(
leading: const Icon(Icons.content_copy_rounded),
title: Text(sheetContext.l10n.metadataCopyValue),
onTap: () => Navigator.pop(sheetContext, 'value'),
),
ListTile(
leading: const Icon(Icons.label_outline_rounded),
title: Text(sheetContext.l10n.metadataCopyField),
onTap: () => Navigator.pop(sheetContext, 'field'),
),
ListTile(
leading: const Icon(Icons.copy_all_rounded),
title: Text(sheetContext.l10n.metadataCopyAll),
onTap: () => Navigator.pop(sheetContext, 'all'),
),
],
),
);
if (!context.mounted || action == null) return;
final text = switch (action) {
'field' => '${metadata.label}: ${metadata.rawValue}',
'all' =>
allMetadata.map((item) => '${item.label}: ${item.rawValue}').join('\n'),
_ => metadata.rawValue,
};
_copyToClipboard(context, text);
}
String _formatLabelForRaw(String raw) {
final normalized = raw.toLowerCase().replaceAll('-', '_');
return switch (normalized) {
+4 -2
View File
@@ -1300,7 +1300,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
artists,
album,
if (date.isNotEmpty) date,
if (isrc.isNotEmpty) 'ISRC $isrc',
if (isrc.isNotEmpty) 'ISRC ${formatIsrcForDisplay(isrc)}',
].where((value) => value.isNotEmpty).join('\n');
return ListTile(
@@ -3095,6 +3095,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
class _MetadataItem {
final String label;
final String value;
final String rawValue;
_MetadataItem(this.label, this.value);
_MetadataItem(this.label, this.value, {String? rawValue})
: rawValue = rawValue ?? value;
}
+19 -5
View File
@@ -245,7 +245,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = !instrumental;
_lyricsEmbedded = true;
_isInstrumental = instrumental;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
@@ -389,7 +389,7 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
_lyricsSource = embeddedSource.isNotEmpty
? embeddedSource
: context.l10n.trackLyricsEmbeddedSource;
_lyricsEmbedded = !embeddedInstrumental;
_lyricsEmbedded = true;
_isInstrumental = embeddedInstrumental;
_lyricsLoading = false;
_embeddedLyricsChecked = true;
@@ -1052,7 +1052,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
);
await _refreshEmbeddedCoverPreview(force: true);
_markMetadataChanged();
await _syncDownloadHistoryMetadata();
await _syncDownloadHistoryMetadata(
hasLyrics: hasUsableLyricsContent(
(result['lyrics'] ?? result['lyrics_lrc'])?.toString() ?? '',
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
@@ -1149,7 +1153,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
);
await _refreshEmbeddedCoverPreview(force: true);
_markMetadataChanged();
await _syncDownloadHistoryMetadata();
await _syncDownloadHistoryMetadata(
hasLyrics: hasUsableLyricsContent(
(result['lyrics'] ?? result['lyrics_lrc'])?.toString() ?? '',
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
@@ -1195,7 +1203,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
}
}
Future<void> _syncDownloadHistoryMetadata() async {
Future<void> _syncDownloadHistoryMetadata({bool? hasLyrics}) async {
final resolvedHasLyrics =
hasLyrics ?? (_embeddedLyricsChecked ? _lyricsEmbedded : null);
if (_isLocalItem) {
final item = _localLibraryItem;
if (item == null) return;
@@ -1203,11 +1213,13 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
await LibraryDatabase.instance.updateAudioMetadata(
item.id,
explicit: isExplicit,
hasLyrics: resolvedHasLyrics,
);
if (mounted) {
_setState(() {
_currentLocalLibraryItem = item.withAudioMetadata(
explicit: isExplicit,
hasLyrics: resolvedHasLyrics,
);
});
}
@@ -1246,6 +1258,8 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
label: normalizedOrNull(label),
copyright: normalizedOrNull(copyright),
explicit: isExplicit,
hasLyrics: resolvedHasLyrics,
lyricsMetadataScanVersion: resolvedHasLyrics == null ? null : 1,
);
} catch (e) {
_log.w('Failed to sync download history metadata: $e');
+20
View File
@@ -37,6 +37,7 @@ import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/user_facing_error.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/isrc_utils.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
import 'package:spotiflac_android/utils/re_enrich_release_policy.dart';
import 'package:spotiflac_android/utils/saf_display_path.dart';
@@ -362,8 +363,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
final resolvedUPC = (metadata['upc'] ?? metadata['barcode'])?.toString();
final resolvedComment = metadata['comment']?.toString();
final resolvedExplicit = parseExplicitFlag(metadata['explicit']);
final hasResolvedLyricsValue =
metadata.containsKey('hasLyrics') || metadata.containsKey('lyrics');
final resolvedHasLyrics =
metadata['hasLyrics'] == true ||
hasUsableLyricsContent(metadata['lyrics']?.toString() ?? '');
final needsExplicit =
resolvedExplicit != null && resolvedExplicit != isExplicit;
final needsLyricsAvailability =
hasResolvedLyricsValue &&
(_isLocalItem
? resolvedHasLyrics != _localLibraryItem?.hasLyrics
: resolvedHasLyrics != _downloadItem?.hasLyrics ||
(_downloadItem?.lyricsMetadataScanVersion ?? 0) < 1);
final needsTrackNumber =
resolvedTrackNumber != null &&
resolvedTrackNumber > 0 &&
@@ -425,6 +437,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
needsDuration ||
needsComposer ||
needsExplicit ||
needsLyricsAvailability ||
(isPlaceholderQualityLabel(_quality) && resolvedQuality != null));
final localItem = _localLibraryItem;
final localAudioMetadataChanged =
@@ -436,6 +449,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
(resolvedBitrate != null &&
resolvedBitrate != localItem.bitrate) ||
needsExplicit ||
needsLyricsAvailability ||
needsDuration ||
formatChanged);
@@ -511,6 +525,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
duration: needsDuration ? resolvedDuration : null,
composer: needsComposer ? resolvedComposer : null,
explicit: needsExplicit ? resolvedExplicit : null,
hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null,
lyricsMetadataScanVersion: hasResolvedLyricsValue ? 1 : null,
);
if (mounted && _downloadItem != null) {
setState(() {
@@ -527,6 +543,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
duration: needsDuration ? resolvedDuration : null,
composer: needsComposer ? resolvedComposer : null,
explicit: needsExplicit ? resolvedExplicit : null,
hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null,
lyricsMetadataScanVersion: hasResolvedLyricsValue ? 1 : null,
);
});
}
@@ -538,6 +556,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
explicit: needsExplicit ? resolvedExplicit : null,
hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null,
format: formatChanged ? resolvedFormat : null,
);
if (mounted &&
@@ -550,6 +569,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen>
sampleRate: resolvedSampleRate,
bitrate: resolvedBitrate,
explicit: needsExplicit ? resolvedExplicit : null,
hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null,
format: resolvedFormat,
);
});
+4 -1
View File
@@ -79,6 +79,9 @@ class BackupService {
static const int formatVersion = 1;
static const String fileExtension = 'json';
static String encode(Map<String, dynamic> envelope) =>
const JsonEncoder.withIndent(' ').convert(envelope);
/// Builds the backup envelope written to disk.
static Map<String, dynamic> buildEnvelope({
required Map<String, dynamic>? settings,
@@ -119,7 +122,7 @@ class BackupService {
final fileName = 'spotiflac_backup_$stamp.$fileExtension';
final file = File(p.join(backupsDir.path, fileName));
await file.writeAsString(jsonEncode(envelope), flush: true);
await file.writeAsString(encode(envelope), flush: true);
_log.i('Backup written to ${file.path}');
return file;
}
+4 -3
View File
@@ -4,6 +4,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
class SavedCoverResult {
final String fileName;
@@ -20,9 +21,9 @@ class CoverDownloadService {
required String baseName,
required AppSettings settings,
}) async {
final normalizedUrl = coverUrl.trim();
if (normalizedUrl.isEmpty) {
throw const FormatException('No cover art source available');
final normalizedUrl = normalizeRemoteHttpUrl(coverUrl);
if (normalizedUrl == null) {
throw const FormatException('No remote cover art source available');
}
final safeBaseName = await PlatformBridge.sanitizeFilename(baseName.trim());
+32 -1
View File
@@ -65,7 +65,7 @@ class HistoryBatchLookupRequest {
}
class HistoryDatabase {
static const int schemaVersion = 12;
static const int schemaVersion = 13;
static final HistoryDatabase instance = HistoryDatabase._init();
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
@@ -120,6 +120,8 @@ class HistoryDatabase {
label TEXT,
copyright TEXT,
explicit INTEGER NOT NULL DEFAULT 0,
has_lyrics INTEGER NOT NULL DEFAULT 0,
lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0,
spotify_id_norm TEXT,
isrc_norm TEXT,
match_key TEXT,
@@ -239,6 +241,21 @@ class HistoryDatabase {
);
_log.i('Added explicit-content metadata');
}
if (oldVersion < 13) {
await sqlite.addColumnIfMissing(
db,
'history',
'has_lyrics',
'INTEGER NOT NULL DEFAULT 0',
);
await sqlite.addColumnIfMissing(
db,
'history',
'lyrics_metadata_scan_version',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed lyrics availability metadata');
}
}
static String normalizeLookupText(String? value) =>
@@ -628,6 +645,10 @@ class HistoryDatabase {
'label': json['label'],
'copyright': json['copyright'],
'explicit': json['explicit'] == true ? 1 : 0,
'has_lyrics': json['hasLyrics'] == true ? 1 : 0,
'lyrics_metadata_scan_version':
(json['lyricsMetadataScanVersion'] as num?)?.toInt() ??
(json.containsKey('hasLyrics') ? 1 : 0),
};
row.addAll(
_queueSortColumns(
@@ -687,6 +708,8 @@ class HistoryDatabase {
'label': row['label'],
'copyright': row['copyright'],
'explicit': row['explicit'] == 1 || row['explicit'] == true,
'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true,
'lyricsMetadataScanVersion': row['lyrics_metadata_scan_version'] ?? 0,
};
}
@@ -1089,6 +1112,8 @@ class HistoryDatabase {
String? newQuality,
int? newBitDepth,
int? newSampleRate,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
final db = await database;
final values = <String, dynamic>{};
@@ -1101,6 +1126,12 @@ class HistoryDatabase {
if (newSampleRate != null) {
values['sample_rate'] = newSampleRate;
}
if (hasLyrics != null) {
values['has_lyrics'] = hasLyrics ? 1 : 0;
}
if (lyricsMetadataScanVersion != null) {
values['lyrics_metadata_scan_version'] = lyricsMetadataScanVersion;
}
if (values.isEmpty) {
return;
}
+19 -3
View File
@@ -16,10 +16,10 @@ final _log = AppLogger('LibraryDatabase');
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static const int schemaVersion = 12;
static const int schemaVersion = 13;
static const String legacySourceId = LocalLibraryItem.legacySourceId;
static const String visibleLibraryView = 'library_visible';
static const int audioMetadataScanVersion = 2;
static const int audioMetadataScanVersion = 3;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
bool _historyAttached = false;
@@ -84,8 +84,9 @@ class LibraryDatabase {
label TEXT,
copyright TEXT,
explicit INTEGER NOT NULL DEFAULT 0,
has_lyrics INTEGER NOT NULL DEFAULT 0,
format TEXT,
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 2,
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 3,
track_name_norm TEXT,
artist_name_norm TEXT,
album_name_norm TEXT,
@@ -211,6 +212,15 @@ class LibraryDatabase {
);
_log.i('Added explicit-content metadata');
}
if (oldVersion < 13) {
await sqlite.addColumnIfMissing(
db,
'library',
'has_lyrics',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed lyrics availability metadata');
}
}
Future<void> _createLibrarySources(DatabaseExecutor db) async {
@@ -470,6 +480,7 @@ class LibraryDatabase {
'label': json['label'],
'copyright': json['copyright'],
'explicit': json['explicit'] == true || json['explicit'] == 1 ? 1 : 0,
'has_lyrics': json['hasLyrics'] == true || json['hasLyrics'] == 1 ? 1 : 0,
'format': json['format'],
'audio_metadata_scan_version':
(json['audioMetadataScanVersion'] as num?)?.toInt() ??
@@ -525,6 +536,7 @@ class LibraryDatabase {
'label': row['label'],
'copyright': row['copyright'],
'explicit': row['explicit'] == 1 || row['explicit'] == true,
'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true,
'format': row['format'],
};
}
@@ -1486,6 +1498,7 @@ class LibraryDatabase {
int? sampleRate,
int? bitrate,
bool? explicit,
bool? hasLyrics,
String? format,
}) async {
final values = <String, dynamic>{};
@@ -1504,6 +1517,9 @@ class LibraryDatabase {
if (explicit != null) {
values['explicit'] = explicit ? 1 : 0;
}
if (hasLyrics != null) {
values['has_lyrics'] = hasLyrics ? 1 : 0;
}
final normalizedFormat = normalizeAudioFormatValue(format);
if (normalizedFormat != null) {
values['format'] = normalizedFormat;
@@ -39,6 +39,7 @@ class LocalLibraryItem {
final String? label;
final String? copyright;
final bool explicit;
final bool hasLyrics;
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
const LocalLibraryItem({
@@ -67,6 +68,7 @@ class LocalLibraryItem {
this.label,
this.copyright,
this.explicit = false,
this.hasLyrics = false,
this.format,
});
@@ -96,6 +98,7 @@ class LocalLibraryItem {
'label': label,
'copyright': copyright,
'explicit': explicit,
'hasLyrics': hasLyrics,
'format': format,
};
@@ -126,6 +129,7 @@ class LocalLibraryItem {
label: json['label'] as String?,
copyright: json['copyright'] as String?,
explicit: json['explicit'] == true || json['explicit'] == 1,
hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1,
format: json['format'] as String?,
);
@@ -135,6 +139,7 @@ class LocalLibraryItem {
int? sampleRate,
int? bitrate,
bool? explicit,
bool? hasLyrics,
String? format,
}) {
return LocalLibraryItem(
@@ -163,6 +168,7 @@ class LocalLibraryItem {
label: label,
copyright: copyright,
explicit: explicit ?? this.explicit,
hasLyrics: hasLyrics ?? this.hasLyrics,
format: format ?? this.format,
);
}
@@ -385,6 +385,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
discNumberExpr: 'h.disc_number',
isrcExpr: 'h.isrc',
labelExpr: 'h.label',
hasLyricsExpr: 'h.has_lyrics',
);
}
@@ -415,6 +416,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
discNumberExpr: 'l.disc_number',
isrcExpr: 'l.isrc',
labelExpr: 'l.label',
hasLyricsExpr: 'l.has_lyrics',
);
}
@@ -434,6 +436,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
required String discNumberExpr,
required String isrcExpr,
required String labelExpr,
required String hasLyricsExpr,
}) {
final quality = request.quality?.trim().toLowerCase();
if (quality != null && quality.isNotEmpty) {
@@ -518,6 +521,9 @@ extension _LibraryDbQueueSql on LibraryDatabase {
case 'missing-label':
where.add('NOT ($hasLabel)');
break;
case 'missing-lyrics':
where.add('COALESCE($hasLyricsExpr, 0) = 0');
break;
}
}
+27
View File
@@ -0,0 +1,27 @@
final RegExp _canonicalIsrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$');
String normalizeIsrc(String? value) =>
(value ?? '').trim().toUpperCase().replaceAll(RegExp(r'[-\s]'), '');
/// Returns the compact canonical value only when [value] is a valid ISRC.
/// Invalid provider/tag values remain visible and copyable without mutation.
String canonicalIsrcForCopy(String? value) {
final original = value?.trim() ?? '';
final normalized = normalizeIsrc(original);
return _canonicalIsrcPattern.hasMatch(normalized) ? normalized : original;
}
/// Formats a valid canonical ISRC for display without changing stored tags.
///
/// Example: `AA9BQ2200061` becomes `AA-9BQ-22-00061`.
String formatIsrcForDisplay(String? value) {
final original = value?.trim() ?? '';
final canonical = canonicalIsrcForCopy(original);
if (!_canonicalIsrcPattern.hasMatch(canonical)) {
return original;
}
return '${canonical.substring(0, 2)}-'
'${canonical.substring(2, 5)}-'
'${canonical.substring(5, 7)}-'
'${canonical.substring(7)}';
}
+32
View File
@@ -0,0 +1,32 @@
/// Adds every visible item between [anchor] and [target] to [selected].
///
/// Dart's default [Set] preserves insertion order, so walking backwards also
/// preserves the direction chosen by the user when results are later copied,
/// shared, or batch processed.
T addOrderedSelectionRange<T>({
required Set<T> selected,
required List<T> visibleItems,
required T target,
T? anchor,
}) {
final anchorIndex = anchor == null ? -1 : visibleItems.indexOf(anchor);
final targetIndex = visibleItems.indexOf(target);
if (anchorIndex < 0 || targetIndex < 0) {
selected.add(target);
return target;
}
final step = targetIndex >= anchorIndex ? 1 : -1;
for (var index = anchorIndex; ; index += step) {
selected.add(visibleItems[index]);
if (index == targetIndex) break;
}
return target;
}
/// Toggles one item and returns the next range-selection anchor.
T? toggleOrderedSelection<T>({required Set<T> selected, required T target}) {
if (selected.add(target)) return target;
selected.remove(target);
return selected.isEmpty ? null : selected.last;
}
+115
View File
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_download_service.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
/// Adds the app-wide long-press-to-save behavior to a remote cover image.
///
/// The child keeps its normal tap behavior. Only the long-press gesture is
/// claimed here, so a surrounding track or collection card can still open its
/// usual options when the user presses outside the artwork.
class DownloadableCover extends ConsumerStatefulWidget {
final String? coverUrl;
final String baseName;
final Widget child;
final bool enabled;
const DownloadableCover({
super.key,
required this.coverUrl,
required this.baseName,
required this.child,
this.enabled = true,
});
@override
ConsumerState<DownloadableCover> createState() => _DownloadableCoverState();
}
class _DownloadableCoverState extends ConsumerState<DownloadableCover> {
bool _saving = false;
String get _coverUrl => normalizeRemoteHttpUrl(widget.coverUrl) ?? '';
Future<void> _saveCover() async {
if (_saving) return;
final coverUrl = _coverUrl;
if (coverUrl.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.trackCoverNoSource)));
return;
}
setState(() => _saving = true);
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(context.l10n.updateDownloading)));
try {
final saved = await CoverDownloadService.saveRemoteCover(
coverUrl: coverUrl,
baseName: widget.baseName,
settings: ref.read(settingsProvider),
);
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverSaved(saved.fileName))),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
context.l10n.trackSaveFailed(context.friendlyError(error)),
),
),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final canSave = widget.enabled && _coverUrl.isNotEmpty;
final semanticLabel =
'${context.l10n.dialogDownload} '
'${context.l10n.editMetadataFieldCover}: ${widget.baseName}';
return Semantics(
button: canSave,
label: semanticLabel,
onLongPress: canSave ? _saveCover : null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onLongPress: canSave ? _saveCover : null,
child: Stack(
fit: StackFit.passthrough,
children: [
widget.child,
if (_saving)
const Positioned.fill(
child: ColoredBox(
color: Color(0x33000000),
child: Center(
child: SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
),
],
),
),
);
}
}
@@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
@@ -194,6 +195,23 @@ class _TrackOptionsSheet extends ConsumerWidget {
onTap: () => _downloadQualityVariant(context, ref),
),
_OptionTile(
icon: Icons.content_copy_rounded,
title: context.l10n.trackOptionCopyTrackName,
onTap: () => _copyText(context, track.name),
),
_OptionTile(
icon: Icons.person_outline_rounded,
title: context.l10n.trackOptionCopyArtist,
onTap: () => _copyText(context, track.artistName),
),
_OptionTile(
icon: Icons.copy_all_rounded,
title: context.l10n.trackOptionCopyTrackAndArtist,
onTap: () =>
_copyText(context, '${track.name} - ${track.artistName}'),
),
_OptionTile(
icon: Icons.album_outlined,
title: context.l10n.homeGoToAlbum,
@@ -275,6 +293,15 @@ class _TrackOptionsSheet extends ConsumerWidget {
downloadSingleTrack(rootContext, ref, track, forceQualityPicker: true);
}
void _copyText(BuildContext context, String text) {
final rootContext = Navigator.of(context, rootNavigator: true).context;
Navigator.pop(context);
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(rootContext).showSnackBar(
SnackBar(content: Text(rootContext.l10n.trackCopiedToClipboard)),
);
}
Future<void> _goToAlbum(BuildContext context) async {
final navigationContext = Navigator.of(
context,