mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-04 17:16:50 +02:00
feat(library): add lyrics filtering and metadata actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -178,6 +178,7 @@ extension _QueueTabBatchActions on _QueueTabState {
|
||||
_setState(() {
|
||||
_selectedIds.clear();
|
||||
_isSelectionMode = false;
|
||||
_selectionAnchorId = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user