diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index f7bcb526..df7a98b9 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -39,7 +39,7 @@ object NativeDownloadFinalizer { const val NATIVE_WORKER_CONTRACT_VERSION = 1 // Native finalizer owns background-safe history writes while Flutter may be suspended. // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. - const val HISTORY_SCHEMA_VERSION = 12 + const val HISTORY_SCHEMA_VERSION = 13 internal val activeFFmpegSessionIds = mutableSetOf() internal val nativeFFmpegSessionIds = BoundedRegistry(maxEntries = 256) internal val activeFFmpegSessionLock = Any() @@ -88,6 +88,8 @@ object NativeDownloadFinalizer { "label", "copyright", "explicit", + "has_lyrics", + "lyrics_metadata_scan_version", "spotify_id_norm", "isrc_norm", "match_key", @@ -137,6 +139,9 @@ object NativeDownloadFinalizer { var audioCodec: String? = null, var pendingExternalLrc: String? = null, var pendingExternalLrcFileName: String? = null, + var lyricsMetadataScanned: Boolean = false, + var hasEmbeddedLyrics: Boolean = false, + var externalLrcWritten: Boolean = false, ) internal data class ReplayGainScan( @@ -997,6 +1002,15 @@ object NativeDownloadFinalizer { val metadata = parseObject(Gobackend.readFileMetadata(probePath)) if (metadata.has("error")) return + if (metadata.has("lyrics") || metadata.has("hasLyrics")) { + state.lyricsMetadataScanned = true + state.hasEmbeddedLyrics = + metadata.optBoolean("hasLyrics", false) || + NativeFinalizationPolicy.hasUsableLyricsContent( + metadata.optString("lyrics", ""), + ) + } + val bitDepth = optPositiveInt(metadata, "bit_depth") val sampleRate = optPositiveInt(metadata, "sample_rate") val probedCodec = normalizeAudioCodec( @@ -1311,6 +1325,18 @@ object NativeDownloadFinalizer { input.request.optBoolean("explicit", false) ) 1 else 0, ) + values.put( + "has_lyrics", + if (state.hasEmbeddedLyrics || state.externalLrcWritten) 1 else 0, + ) + values.put( + "lyrics_metadata_scan_version", + if ( + state.lyricsMetadataScanned || + state.hasEmbeddedLyrics || + state.externalLrcWritten + ) 1 else 0, + ) putNormalizedHistoryColumns(values) return values } @@ -1374,6 +1400,8 @@ object NativeDownloadFinalizer { 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, @@ -1412,6 +1440,8 @@ object NativeDownloadFinalizer { ensureHistoryColumn(db, "sort_release", "ALTER TABLE history ADD COLUMN sort_release TEXT") ensureHistoryColumn(db, "sort_added", "ALTER TABLE history ADD COLUMN sort_added INTEGER") ensureHistoryColumn(db, "explicit", "ALTER TABLE history ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0") + ensureHistoryColumn(db, "has_lyrics", "ALTER TABLE history ADD COLUMN has_lyrics INTEGER NOT NULL DEFAULT 0") + ensureHistoryColumn(db, "lyrics_metadata_scan_version", "ALTER TABLE history ADD COLUMN lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0") ensureHistoryPathKeyTable(db) if (needsBackfill) { backfillNormalizedHistoryColumns(db) @@ -1937,6 +1967,9 @@ object NativeDownloadFinalizer { putCamel("composer", "composer") putCamel("label", "label") putCamel("copyright", "copyright") + json.put("explicit", values.getAsInteger("explicit") == 1) + json.put("hasLyrics", values.getAsInteger("has_lyrics") == 1) + json.put("lyricsMetadataScanVersion", values.getAsInteger("lyrics_metadata_scan_version") ?: 0) return json } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt index 77b6323b..f2ad7b08 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -11,6 +11,51 @@ import kotlin.math.roundToInt * finalizer's I/O-heavy orchestration. */ internal object NativeFinalizationPolicy { + private val lyricsMetadataLinePattern = Regex( + "^\\[[a-z][a-z0-9_]*:.*]$", + RegexOption.IGNORE_CASE, + ) + private val lyricsBackgroundPattern = Regex( + "^\\[bg:(.*)]$", + RegexOption.IGNORE_CASE, + ) + private val lyricsTimestampPattern = Regex( + "^\\[\\d{1,3}:\\d{1,2}(?:[.:]\\d{1,3})?]", + ) + private val lyricsInlineTimestampPattern = Regex( + "<\\d{1,3}:\\d{1,2}(?:[.:]\\d{1,3})?>", + ) + + fun hasUsableLyricsContent(raw: String?): Boolean { + val lyrics = raw.orEmpty().trim() + if (lyrics.equals("[instrumental:true]", ignoreCase = true)) return true + + for (line in lyrics.lineSequence()) { + var cleaned = line.trim() + if (cleaned.isEmpty()) continue + + val background = lyricsBackgroundPattern.matchEntire(cleaned) + if (background != null) { + cleaned = background.groupValues[1].trim() + } else if (lyricsMetadataLinePattern.matches(cleaned)) { + continue + } + + while (lyricsTimestampPattern.containsMatchIn(cleaned)) { + cleaned = lyricsTimestampPattern.replaceFirst(cleaned, "").trim() + } + cleaned = lyricsInlineTimestampPattern.replace(cleaned, "").trim() + if ( + cleaned.startsWith("v1:", ignoreCase = true) || + cleaned.startsWith("v2:", ignoreCase = true) + ) { + cleaned = cleaned.drop(3).trim() + } + if (cleaned.isNotEmpty()) return true + } + return false + } + data class AutoConversionTarget( val codec: String, val extension: String, diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt index 766afcc7..12e7fdc5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerMedia.kt @@ -177,7 +177,10 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N val lyricsMode = input.request.optString("lyrics_mode", "") if (lyricsMode != "external" && lyricsMode != "both") return val lrc = resolveLyricsLrc(input) - if (lrc.isBlank() || lrc == "[instrumental:true]") return + if ( + !NativeFinalizationPolicy.hasUsableLyricsContent(lrc) || + lrc.trim().equals("[instrumental:true]", ignoreCase = true) + ) return val audioFileName = if (isDeferredSafRequest(input)) { desiredFileName(input, state, File(state.filePath).extension) } else { @@ -195,7 +198,7 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc") temp.writeText(lrc) try { - SafDownloadHandler.writeFileToSaf( + val uri = SafDownloadHandler.writeFileToSaf( context = context, treeUriStr = treeUri, relativeDir = relativeDir, @@ -203,12 +206,14 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N mimeType = "application/octet-stream", srcPath = temp.absolutePath, ) + state.externalLrcWritten = uri != null } finally { temp.delete() } } else { val target = File(File(state.filePath).parentFile, "$baseName.lrc") target.writeText(lrc) + state.externalLrcWritten = true } } @@ -300,8 +305,8 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path: (lyricsMode == "embed" || lyricsMode == "both") val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else "" val shouldEmbedLyrics = shouldResolveLyrics && - lyrics.isNotBlank() && - lyrics != "[instrumental:true]" + NativeFinalizationPolicy.hasUsableLyricsContent(lyrics) && + !lyrics.trim().equals("[instrumental:true]", ignoreCase = true) // FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the // tag block atomically without an ffmpeg remux (which drops foreign // frames and rewrites the whole container). The Go side answers diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt index 99bab16b..2af92cd0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt @@ -181,6 +181,8 @@ internal fun NativeDownloadFinalizer.publishPendingDeferredExternalLrc( ) if (newUri == null) { Log.w(TAG, "Failed to publish deferred external LRC: $fileName") + } else { + state.externalLrcWritten = true } } catch (e: Exception) { Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}") diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt index 8808e8ce..30bf4f6c 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -7,6 +7,31 @@ import org.junit.Assert.assertTrue import org.junit.Test class NativeFinalizationPolicyTest { + @Test + fun usableLyricsRejectsHeadersButKeepsRealAndInstrumentalContent() { + assertFalse( + NativeFinalizationPolicy.hasUsableLyricsContent( + "[ar:Artist]\n[ti:Title]\n[offset:0]", + ), + ) + assertFalse( + NativeFinalizationPolicy.hasUsableLyricsContent( + "[00:01.00]\n<00:01.10>\nv1:", + ), + ) + assertTrue( + NativeFinalizationPolicy.hasUsableLyricsContent( + "[00:01.00]<00:01.10>v1: First line", + ), + ) + assertTrue( + NativeFinalizationPolicy.hasUsableLyricsContent("[bg:Backing vocal]"), + ) + assertTrue( + NativeFinalizationPolicy.hasUsableLyricsContent("[instrumental:true]"), + ) + } + @Test fun automaticConversionSettingsAreNormalizedAndComparable() { val target = checkNotNull( diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 083ab456..7f92daec 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -39,6 +39,7 @@ type LibraryScanResult struct { Comment string `json:"comment,omitempty"` AlbumType string `json:"albumType,omitempty"` Explicit bool `json:"explicit,omitempty"` + HasLyrics bool `json:"hasLyrics"` UPC string `json:"upc,omitempty"` Format string `json:"format,omitempty"` MetadataFromFilename bool `json:"metadataFromFilename,omitempty"` diff --git a/go_backend/library_scan_formats.go b/go_backend/library_scan_formats.go index cec26e59..26d46dc4 100644 --- a/go_backend/library_scan_formats.go +++ b/go_backend/library_scan_formats.go @@ -30,38 +30,49 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ libraryCoverCacheMu.RLock() coverCacheDir := libraryCoverCacheDir libraryCoverCacheMu.RUnlock() + var scanned *LibraryScanResult + var scanErr error if ext == ".flac" { - return scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey) - } - if ext == ".m4a" || ext == ".mp4" || ext == ".aac" { - return scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey) - } - if coverCacheDir != "" { - coverPath, err := SaveCoverToCacheWithHintAndKey( - filePath, - displayNameHint, - coverCacheDir, - coverCacheKey, - ) - if err == nil && coverPath != "" { - result.CoverPath = coverPath + scanned, scanErr = scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey) + } else if ext == ".m4a" || ext == ".mp4" || ext == ".aac" { + scanned, scanErr = scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey) + } else { + if coverCacheDir != "" { + coverPath, err := SaveCoverToCacheWithHintAndKey( + filePath, + displayNameHint, + coverCacheDir, + coverCacheKey, + ) + if err == nil && coverPath != "" { + result.CoverPath = coverPath + } + } + + switch ext { + case ".mp3": + scanned, scanErr = scanMP3File(filePath, result, displayNameHint) + case ".opus", ".ogg": + scanned, scanErr = scanOggFile(filePath, result, displayNameHint) + case ".ape", ".wv", ".mpc": + scanned, scanErr = scanAPEFile(filePath, result, displayNameHint) + case ".wav": + scanned, scanErr = scanWAVFile(filePath, result, displayNameHint) + case ".aiff", ".aif", ".aifc": + scanned, scanErr = scanAIFFFile(filePath, result, displayNameHint) + default: + scanned, scanErr = scanFromFilename(filePath, displayNameHint, result) } } - - switch ext { - case ".mp3": - return scanMP3File(filePath, result, displayNameHint) - case ".opus", ".ogg": - return scanOggFile(filePath, result, displayNameHint) - case ".ape", ".wv", ".mpc": - return scanAPEFile(filePath, result, displayNameHint) - case ".wav": - return scanWAVFile(filePath, result, displayNameHint) - case ".aiff", ".aif", ".aifc": - return scanAIFFFile(filePath, result, displayNameHint) - default: - return scanFromFilename(filePath, displayNameHint, result) + if scanErr != nil || scanned == nil { + return scanned, scanErr } + if !scanned.HasLyrics { + if sidecar, err := extractLyricsFromSidecarLRC(filePath); err == nil { + scanned.HasLyrics = rawLyricsHasUsableContent(sidecar) + } + } + return scanned, nil } func embeddedCoverMIME(data []byte) string { @@ -144,6 +155,7 @@ func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, disp result.Comment = metadata.Comment result.AlbumType = metadata.AlbumType result.Explicit = metadata.Explicit + result.HasLyrics = rawLyricsHasUsableContent(metadata.Lyrics) result.UPC = metadata.UPC quality, err := audioQualityFromParsedFlac(f) diff --git a/go_backend/wav_aiff.go b/go_backend/wav_aiff.go index 65d7c1a7..2eb97e98 100644 --- a/go_backend/wav_aiff.go +++ b/go_backend/wav_aiff.go @@ -976,6 +976,7 @@ func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult result.Comment = metadata.Comment result.AlbumType = metadata.AlbumType result.Explicit = metadata.Explicit + result.HasLyrics = rawLyricsHasUsableContent(metadata.Lyrics) result.UPC = metadata.UPC } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7ed4dbc5..56a0a46e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b38f2a29..b928a16e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5b20806d..fa16e4b7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 620efbd2..031e4dc2 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index dfd07361..b164e9c8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 2ba1d090..0d3079c6 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index baca48fb..35f5bd25 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index b3fa40be..364dc364 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 9bf64110..4e0a4163 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 45d2a878..8fe8ed55 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index a79adbc1..c341c237 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index aa620155..bfca1089 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -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'; } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 11e672af..412ddd00 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" } } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 14fad0d1..e9db3d0e 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" } diff --git a/lib/providers/download_history_models.dart b/lib/providers/download_history_models.dart index 027436e3..f74cb6e3 100644 --- a/lib/providers/download_history_models.dart +++ b/lib/providers/download_history_models.dart @@ -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 toJson() => { @@ -102,6 +106,8 @@ class DownloadHistoryItem { 'label': label, 'copyright': copyright, 'explicit': explicit, + 'hasLyrics': hasLyrics, + 'lyricsMetadataScanVersion': lyricsMetadataScanVersion, }; factory DownloadHistoryItem.fromJson(Map 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, ); } } diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index 287e4724..a2b15a11 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -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 { 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 { 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 { duration: duration, composer: composer, explicit: explicit, + hasLyrics: hasLyrics, + lyricsMetadataScanVersion: lyricsMetadataScanVersion, ); if (updated.quality == current.quality && @@ -515,7 +529,10 @@ class DownloadHistoryNotifier extends Notifier { 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 { 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 { label: label, copyright: copyright, explicit: explicit, + hasLyrics: hasLyrics, + lyricsMetadataScanVersion: lyricsMetadataScanVersion, ); final updatedItems = target.index >= 0 diff --git a/lib/providers/download_history_provider_maintenance.dart b/lib/providers/download_history_provider_maintenance.dart index e0010678..1d643784 100644 --- a/lib/providers/download_history_provider_maintenance.dart +++ b/lib/providers/download_history_provider_maintenance.dart @@ -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 _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?> _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? 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; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 0768c35d..38d69d55 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -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'; diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart index 80188f62..3cd67ed7 100644 --- a/lib/providers/download_queue_provider_embedding.dart +++ b/lib/providers/download_queue_provider_embedding.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) { diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index f0d63986..3c4c1ee5 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -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? 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 _writeLrcToSaf({ + Future _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 _saveExternalLrc({ + Future _saveExternalLrc({ required Map 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; } } } diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 4c088af6..d43fb76a 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -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, ); diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 125c7003..705052c2 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -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? probedFinalMetadata; + bool externalLrcWritten = false; Future _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 _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, ); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 529d20de..654c98d1 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -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>( @@ -437,21 +438,29 @@ class _AlbumScreenState extends ConsumerState 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, + ), ), ), ) diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index f4e57b76..dd14e4a3 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -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'; diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index fcfdcbe9..7ea7e414 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/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( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e15e67cc..b8cb8d17 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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 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( diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index ec657f38..8e23d309 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -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( diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index e4b7fd0a..599fc30f 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -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( diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 81897569..db5ccf42 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -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( diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index dcfc01d2..57c15774 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -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()), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index bd10c21a..8af5ba53 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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 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 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, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5633aae5..43381703 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { bool _isSelectionMode = false; final Set _selectedIds = {}; + String? _selectionAnchorId; final SelectionOverlayController _selectionOverlay = SelectionOverlayController(); List _selectionOverlayItems = const []; @@ -1057,6 +1059,17 @@ class _QueueTabState extends ConsumerState { () => tempMetadata = 'missing-label', ), ), + FilterChip( + label: Text( + context + .l10n + .libraryFilterMetadataMissingLyrics, + ), + selected: tempMetadata == 'missing-lyrics', + onSelected: (_) => setSheetState( + () => tempMetadata = 'missing-lyrics', + ), + ), ], ), const SizedBox(height: 16), diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index cc113630..678e51cd 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -178,6 +178,7 @@ extension _QueueTabBatchActions on _QueueTabState { _setState(() { _selectedIds.clear(); _isSelectionMode = false; + _selectionAnchorId = null; }); } diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index c070a996..4e672845 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -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), diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index e7538b60..321a70fa 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -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 items) { _setState(() { _selectedIds.addAll(items.map((e) => e.id)); + _selectionAnchorId = items.lastOrNull?.id; + }); + } + + void _selectRangeTo(String itemId, List 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); }); diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index ba627759..4a675c92 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -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 { bool _autoScroll = true; bool _selectionMode = false; final Set _selectedEntries = {}; + LogEntry? _selectionAnchor; final List _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR']; @@ -52,7 +54,12 @@ class _LogScreenState extends State { _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 { _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 { _selectedEntries.addAll(visibleLogs); } _selectionMode = _selectedEntries.isNotEmpty; + _selectionAnchor = _selectedEntries.isEmpty + ? null + : allVisibleSelected + ? _selectedEntries.last + : visibleLogs.lastOrNull; }); } Future _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 { 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 { onPressed: () { _selectionMode = false; _selectedEntries.clear(); + _selectionAnchor = null; LogBuffer().clear(); Navigator.pop(context); }, @@ -249,6 +287,13 @@ class _LogScreenState extends State { ? 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 { selectionMode: _selectionMode, selected: _selectedEntries.contains(log), onTap: () => _toggleEntrySelection(log), - onLongPress: () => _enterSelectionMode(log), + onLongPress: () => _selectionMode + ? _selectRangeTo(log) + : _enterSelectionMode(log), ); }), ], diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 7e7eedcf..5e85a03e 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -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; diff --git a/lib/screens/track_metadata_cards.dart b/lib/screens/track_metadata_cards.dart index 33cdf0d8..b79bb7a7 100644 --- a/lib/screens/track_metadata_cards.dart +++ b/lib/screens/track_metadata_cards.dart @@ -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 _showMetadataCopySheet( + BuildContext context, { + required _MetadataItem metadata, + required List<_MetadataItem> allMetadata, + }) async { + final action = await showAppBottomSheet( + 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) { diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index f555ec2c..0e5c3f17 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -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; } diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index 719d12c5..b9429c24 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -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 _syncDownloadHistoryMetadata() async { + Future _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'); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index b2de5d3c..8bc77ac7 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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 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 needsDuration || needsComposer || needsExplicit || + needsLyricsAvailability || (isPlaceholderQualityLabel(_quality) && resolvedQuality != null)); final localItem = _localLibraryItem; final localAudioMetadataChanged = @@ -436,6 +449,7 @@ class _TrackMetadataScreenState extends ConsumerState (resolvedBitrate != null && resolvedBitrate != localItem.bitrate) || needsExplicit || + needsLyricsAvailability || needsDuration || formatChanged); @@ -511,6 +525,8 @@ class _TrackMetadataScreenState extends ConsumerState 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 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 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 sampleRate: resolvedSampleRate, bitrate: resolvedBitrate, explicit: needsExplicit ? resolvedExplicit : null, + hasLyrics: hasResolvedLyricsValue ? resolvedHasLyrics : null, format: resolvedFormat, ); }); diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index 79e009ca..eab8cbca 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -79,6 +79,9 @@ class BackupService { static const int formatVersion = 1; static const String fileExtension = 'json'; + static String encode(Map envelope) => + const JsonEncoder.withIndent(' ').convert(envelope); + /// Builds the backup envelope written to disk. static Map buildEnvelope({ required Map? 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; } diff --git a/lib/services/cover_download_service.dart b/lib/services/cover_download_service.dart index 45d16c98..98ba2d42 100644 --- a/lib/services/cover_download_service.dart +++ b/lib/services/cover_download_service.dart @@ -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()); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index cf3c7056..b93112b3 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -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 = sqlite.SingleFlightInitializer(); @@ -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 = {}; @@ -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; } diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 9eb4c871..3c91adae 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -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 = sqlite.SingleFlightInitializer(); 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 _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 = {}; @@ -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; diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index a750d000..550939d2 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -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, ); } diff --git a/lib/services/library_database_queue_sql.dart b/lib/services/library_database_queue_sql.dart index 25d9424f..eaacc70b 100644 --- a/lib/services/library_database_queue_sql.dart +++ b/lib/services/library_database_queue_sql.dart @@ -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; } } diff --git a/lib/utils/isrc_utils.dart b/lib/utils/isrc_utils.dart new file mode 100644 index 00000000..1099a189 --- /dev/null +++ b/lib/utils/isrc_utils.dart @@ -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)}'; +} diff --git a/lib/utils/ordered_range_selection.dart b/lib/utils/ordered_range_selection.dart new file mode 100644 index 00000000..ff77b641 --- /dev/null +++ b/lib/utils/ordered_range_selection.dart @@ -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({ + required Set selected, + required List 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({required Set selected, required T target}) { + if (selected.add(target)) return target; + selected.remove(target); + return selected.isEmpty ? null : selected.last; +} diff --git a/lib/widgets/downloadable_cover.dart b/lib/widgets/downloadable_cover.dart new file mode 100644 index 00000000..3bd9a91f --- /dev/null +++ b/lib/widgets/downloadable_cover.dart @@ -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 createState() => _DownloadableCoverState(); +} + +class _DownloadableCoverState extends ConsumerState { + bool _saving = false; + + String get _coverUrl => normalizeRemoteHttpUrl(widget.coverUrl) ?? ''; + + Future _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), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 3c760905..a104e698 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -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 _goToAlbum(BuildContext context) async { final navigationContext = Navigator.of( context, diff --git a/test/issue_542_utils_test.dart b/test/issue_542_utils_test.dart new file mode 100644 index 00000000..2c3529c0 --- /dev/null +++ b/test/issue_542_utils_test.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/backup_service.dart'; +import 'package:spotiflac_android/utils/isrc_utils.dart'; +import 'package:spotiflac_android/utils/ordered_range_selection.dart'; + +void main() { + group('issue 542 utility behavior', () { + test('formats valid ISRC for display without changing canonical value', () { + expect(formatIsrcForDisplay('aa9bq2200061'), 'AA-9BQ-22-00061'); + expect(normalizeIsrc('AA-9BQ-22-00061'), 'AA9BQ2200061'); + expect(canonicalIsrcForCopy('aa-9bq-22-00061'), 'AA9BQ2200061'); + expect(formatIsrcForDisplay('not-an-isrc'), 'not-an-isrc'); + expect(canonicalIsrcForCopy('not-an-isrc'), 'not-an-isrc'); + }); + + test('range selection follows the user direction and insertion order', () { + final selected = {'d'}; + + final anchor = addOrderedSelectionRange( + selected: selected, + visibleItems: const ['a', 'b', 'c', 'd', 'e'], + anchor: 'd', + target: 'b', + ); + + expect(anchor, 'b'); + expect(selected.toList(), const ['d', 'c', 'b']); + }); + + test('single selection toggle keeps the latest selected anchor', () { + final selected = {}; + expect(toggleOrderedSelection(selected: selected, target: 'a'), 'a'); + expect(toggleOrderedSelection(selected: selected, target: 'c'), 'c'); + expect(toggleOrderedSelection(selected: selected, target: 'c'), 'a'); + expect(selected.toList(), const ['a']); + }); + + test('backup encoding is indented and remains parseable', () { + final encoded = BackupService.encode({ + 'magic': BackupService.magic, + 'data': { + 'history': [ + {'id': 'one'}, + ], + }, + }); + + expect(encoded, contains('\n "magic"')); + expect(encoded, contains('\n "history"')); + expect(jsonDecode(encoded), isA>()); + }); + }); +}