diff --git a/CHANGELOG.md b/CHANGELOG.md index 8490b61e..f149e1db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [3.7.0] - 2026-02-18 + +### Added + +- **Multi-select Share**: Share multiple downloaded/local tracks at once from the selection bottom bar + - Supports SAF content URIs via native `ACTION_SEND_MULTIPLE` intent + - Supports regular file paths via SharePlus + - Available in Downloaded Album, Local Album, and Queue tab screens +- **Multi-select Batch Convert**: Convert multiple selected tracks to MP3 or Opus in one operation + - Bottom sheet UI with format (MP3 / Opus) and bitrate (128k / 192k / 256k / 320k) selection + - Full SAF support: copies to temp, converts, writes back, deletes original, updates history + - Progress and result snackbar feedback during conversion + - Available in Downloaded Album, Local Album, and Queue tab screens +- **Native `shareMultipleContentUris`**: New Android `ACTION_SEND_MULTIPLE` handler in `MainActivity` for sharing multiple SAF content URIs +- **Localization**: Added selection share/convert strings to all 13 supported locales (`selectionShareCount`, `selectionShareNoFiles`, `selectionConvertCount`, `selectionConvertNoConvertible`, `selectionBatchConvertConfirmTitle`, `selectionBatchConvertConfirmMessage`, `selectionBatchConvertProgress`, `selectionBatchConvertSuccess`) + +### Changed + +- **Local Album Multi-select Action Updated**: Replaced batch `Share` action with batch `Re-enrich` + - Local album selection bar now uses `Re-enrich` + `Convert` actions + - Added batch re-enrich processing for local tracks (FLAC native path and MP3/Opus FFmpeg path, including SAF write-back flow) + - After batch re-enrich completes, local library is refreshed via incremental scan so updated metadata appears in UI immediately +- **Queue Multi-select Local Action Updated**: Queue selection bar now switches the first action to `Re-enrich` when selected items are local-only + - If selection contains downloaded or mixed items, action remains `Share` + - Local-only selection now supports batch re-enrich with the same native/FFmpeg + SAF flow and auto-refreshes local library metadata after completion + +--- + ## [3.6.9] - 2026-02-17 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index be542a83..83caa3be 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build -import android.os.ParcelFileDescriptor import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile import io.flutter.embedding.android.FlutterActivityLaunchConfigs.BackgroundMode @@ -666,22 +665,12 @@ class MainActivity: FlutterFragmentActivity() { val pfd = contentResolver.openFileDescriptor(document.uri, "rw") ?: return errorJson("Failed to open SAF file") - var fdHandedOffToGo = false try { - // Keep the original PFD open so the document provider receives close signaling. - // Pass a duplicated FD to Go and detach only the duplicate. - val writerPfd = ParcelFileDescriptor.dup(pfd.fileDescriptor) - val detachedFd = writerPfd.detachFd() - try { - writerPfd.close() - } catch (_: Exception) {} - - // After detach, ownership is intended for Go. Kotlin must never close this FD, - // otherwise Android fdsan may abort on double-close during cancellation races. - fdHandedOffToGo = true - req.put("output_path", "/proc/self/fd/$detachedFd") - req.put("output_fd", detachedFd) + // Keep SAF PFD ownership in Kotlin and pass only procfs path to Go. + // Go re-opens this procfs FD path for writing to avoid raw FD ownership handoff. + req.put("output_path", "/proc/self/fd/${pfd.fd}") + req.put("output_fd", 0) req.put("output_ext", outputExt) val response = downloader(req.toString()) val respObj = JSONObject(response) @@ -696,9 +685,6 @@ class MainActivity: FlutterFragmentActivity() { document.delete() return errorJson("SAF download failed: ${e.message}") } finally { - if (!fdHandedOffToGo) { - android.util.Log.w("SpotiFLAC", "SAF writer FD was not handed off to Go") - } try { pfd.close() } catch (_: Exception) {} diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 86112467..4ccfc627 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "fmt" "io" + "math" "os" "path/filepath" "strconv" @@ -1127,17 +1128,33 @@ func GetOggQuality(filePath string) (*OggQuality, error) { // Opus always uses 48kHz granule position internally totalSamples := granule - int64(preSkip) if totalSamples > 0 { - quality.Duration = int(totalSamples / 48000) + durationSec := float64(totalSamples) / 48000.0 + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } } } else if quality.SampleRate > 0 { - quality.Duration = int(granule / int64(quality.SampleRate)) + durationSec := float64(granule) / float64(quality.SampleRate) + if durationSec > 0 { + quality.Duration = int(math.Round(durationSec)) + quality.Bitrate = int(float64(fileSize*8) / durationSec) + } } } - // Calculate average bitrate from file size and actual duration - if quality.Duration > 0 { + // Fallback bitrate estimate if duration exists but bitrate couldn't be derived. + if quality.Bitrate <= 0 && quality.Duration > 0 { quality.Bitrate = int(fileSize * 8 / int64(quality.Duration)) } + // Guard against obviously invalid values from corrupted/unreliable granule reads. + if quality.Duration > 24*60*60 { + quality.Duration = 0 + quality.Bitrate = 0 + } + if quality.Bitrate > 0 && quality.Bitrate < 8000 { + quality.Bitrate = 0 + } return quality, nil } @@ -1162,21 +1179,35 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { } buf = buf[:n] - // Scan backwards for "OggS" magic - lastPageOffset := -1 for i := n - 4; i >= 0; i-- { - if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' { - lastPageOffset = i - break + if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' { + continue } + if i+27 > n { + continue + } + // Validate minimal header fields to avoid false positives inside payload bytes. + version := buf[i+4] + headerType := buf[i+5] + if version != 0 || headerType > 0x07 { + continue + } + segmentCount := int(buf[i+26]) + headerLen := 27 + segmentCount + if i+headerLen > n { + continue + } + payloadLen := 0 + for s := 0; s < segmentCount; s++ { + payloadLen += int(buf[i+27+s]) + } + if i+headerLen+payloadLen > n { + continue + } + // Granule position is at bytes 6-13 of the Ogg page header (little-endian int64). + return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14])) } - - if lastPageOffset < 0 || lastPageOffset+14 > n { - return 0 - } - - // Granule position is at bytes 6-13 of the Ogg page header (little-endian int64) - return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14])) + return 0 } // ============================================================================= diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index 53a2bd3f..248e28fd 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -14,6 +14,13 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { if isFDOutput(outputFD) { return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil } + + path := strings.TrimSpace(outputPath) + if strings.HasPrefix(path, "/proc/self/fd/") { + // Re-open procfs fd path instead of taking ownership of raw detached fd. + return os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + } + return os.Create(outputPath) } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 195b2f38..847fd92b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5125,7 +5125,7 @@ abstract class AppLocalizations { /// Menu action - re-embed metadata into audio file /// /// In en, this message translates to: - /// **'Re-enrich Metadata'** + /// **'Re-enrich'** String get trackReEnrich; /// Subtitle for re-enrich metadata action diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 51d9f9b2..8bfc7e07 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2907,7 +2907,7 @@ class AppLocalizationsDe extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 444ac354..91db54f6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsEn extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index bf82005a..cfc8e46a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsEs extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -5908,7 +5908,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index b5f1a283..f1354665 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2892,7 +2892,7 @@ class AppLocalizationsFr extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index d18df768..bb4ad22a 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsHi extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a5205b83..561eff10 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2899,7 +2899,7 @@ class AppLocalizationsId extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 82fd113e..2e806040 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2872,7 +2872,7 @@ class AppLocalizationsJa extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f26d0895..d0909a4d 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2885,7 +2885,7 @@ class AppLocalizationsKo extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 397122f8..6de6d29d 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsNl extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 442e8f1b..e39e5e64 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsPt extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -5902,7 +5902,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e3d6a1d8..95b1295b 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2983,7 +2983,7 @@ class AppLocalizationsRu extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 0c14cf80..15e12f36 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2901,7 +2901,7 @@ class AppLocalizationsTr extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2d4a94c4..f88c9131 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2886,7 +2886,7 @@ class AppLocalizationsZh extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -5875,7 +5875,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => @@ -8808,7 +8808,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich Metadata'; + String get trackReEnrich => 'Re-enrich'; @override String get trackReEnrichSubtitle => diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 9f356f1b..e8dba80d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7a6dd20c..7853c4fe 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2187,7 +2187,7 @@ "@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"}, "trackSaveLyricsProgress": "Saving lyrics...", "@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"}, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"}, "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", "@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"}, diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 4e0dfd9b..5643ebe2 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 41685440..41034185 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 8f6ab38f..71d38aab 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 16a9f483..8e2d9214 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3809,7 +3809,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index fd193b3c..cef5e33a 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 9d982e1a..5627cd07 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 8bae60f8..e331bf27 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 2caeebcb..75810ad5 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 2025e802..f29925ea 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 877cefc2..e51150d8 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index f200ce55..f55e8b80 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 168506b3..954569ce 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -3750,7 +3750,7 @@ "@trackSaveLyricsProgress": { "description": "Snackbar while saving lyrics to file" }, - "trackReEnrich": "Re-enrich Metadata", + "trackReEnrich": "Re-enrich", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 8faa85a7..12714ac6 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1019,7 +1019,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { width: 40, height: 4, decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), borderRadius: BorderRadius.circular(2), ), ), @@ -1051,8 +1053,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - selectedBitrate = - format == 'Opus' ? '128k' : '320k'; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; }); } }, @@ -1102,7 +1105,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), child: Text( - context.l10n.selectionConvertCount(_selectedIds.length), + context.l10n.selectionConvertCount( + _selectedIds.length, + ), ), ), ), @@ -1127,12 +1132,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { final item = tracksById[id]; if (item == null) continue; // For SAF items, use safFileName to detect format (filePath is content:// URI) - final nameToCheck = (item.safFileName != null && item.safFileName!.isNotEmpty) + final nameToCheck = + (item.safFileName != null && item.safFileName!.isNotEmpty) ? item.safFileName!.toLowerCase() : item.filePath.toLowerCase(); - final ext = nameToCheck.endsWith('.flac') ? 'FLAC' - : nameToCheck.endsWith('.mp3') ? 'MP3' - : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) ? 'Opus' + final ext = nameToCheck.endsWith('.flac') + ? 'FLAC' + : nameToCheck.endsWith('.mp3') + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' : null; if (ext != null && ext != targetFormat) selected.add(item); } @@ -1152,7 +1161,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( context.l10n.selectionBatchConvertConfirmMessage( - selected.length, targetFormat, bitrate, + selected.length, + targetFormat, + bitrate, ), ), actions: [ @@ -1173,6 +1184,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { int successCount = 0; final total = selected.length; final historyDb = HistoryDatabase.instance; + final newQuality = + '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -1181,7 +1194,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.selectionBatchConvertProgress(i + 1, total)), + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), duration: const Duration(seconds: 30), ), ); @@ -1210,7 +1225,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { final coverOutput = '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, coverOutput, + item.filePath, + coverOutput, ); if (coverResult['error'] == null) coverPath = coverOutput; } catch (_) {} @@ -1220,7 +1236,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { String? safTempPath; if (isSaf) { - safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath); + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); if (safTempPath == null) continue; workingPath = safTempPath; } @@ -1235,12 +1253,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); if (coverPath != null) { - try { await File(coverPath).delete(); } catch (_) {} + try { + await File(coverPath).delete(); + } catch (_) {} } if (newPath == null) { if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } @@ -1251,10 +1273,16 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (treeUri != null && treeUri.isNotEmpty) { final oldFileName = item.safFileName ?? ''; final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -1265,22 +1293,43 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); if (safUri == null || safUri.isEmpty) { - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } - try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} - await historyDb.updateFilePath(item.id, safUri, newSafFileName: newFileName); + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} + await historyDb.updateFilePath( + item.id, + safUri, + newSafFileName: newFileName, + newQuality: newQuality, + clearAudioSpecs: true, + ); } - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } } else { - await historyDb.updateFilePath(item.id, newPath); + await historyDb.updateFilePath( + item.id, + newPath, + newQuality: newQuality, + clearAudioSpecs: true, + ); } successCount++; @@ -1295,7 +1344,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat), + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), ), ), ); @@ -1354,7 +1407,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.downloadedAlbumSelectedCount(selectedCount), + context.l10n.downloadedAlbumSelectedCount( + selectedCount, + ), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 5d654943..aca09bfc 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -3,9 +3,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; @@ -578,7 +578,11 @@ class _LocalAlbumScreenState extends ConsumerState { } // For lossless formats, use bit depth / sample rate - if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null; + if (first.bitDepth == null || + first.bitDepth == 0 || + first.sampleRate == null) { + return null; + } final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; @@ -824,47 +828,246 @@ class _LocalAlbumScreenState extends ConsumerState { ); } - /// Share selected local tracks - Future _shareSelected(List allTracks) async { - final tracksById = {for (final t in allTracks) t.id: t}; - final safUris = []; - final filesToShare = []; + bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; - for (final id in _selectedIds) { - final item = tracksById[id]; - if (item == null) continue; - final path = item.filePath; - if (isContentUri(path)) { - if (await fileExists(path)) safUris.add(path); - } else if (await fileExists(path)) { - filesToShare.add(XFile(path)); + Future _safeDeleteFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); } - } + } catch (_) {} + } - if (safUris.isEmpty && filesToShare.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.selectionShareNoFiles)), - ); + Future _cleanupTempFileAndParent(String path) async { + await _safeDeleteFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); } - return; - } + } catch (_) {} + } - // Share SAF content URIs via native intent - if (safUris.isNotEmpty) { + Future _applyFfmpegReEnrichResult( + LocalLibraryItem item, + Map result, + ) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasValue(effectiveCoverPath)) { try { - if (safUris.length == 1) { - await PlatformBridge.shareContentUri(safUris.first); + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; } else { - await PlatformBridge.shareMultipleContentUris(safUris); + try { + await tempDir.delete(recursive: true); + } catch (_) {} } } catch (_) {} } - // Share regular files via SharePlus - if (filesToShare.isNotEmpty) { - await SharePlus.instance.share(ShareParams(files: filesToShare)); + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); } + + if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + await _safeDeleteFile(tempPath!); + return false; + } + } + + if (_hasValue(downloadedCoverPath)) { + await _safeDeleteFile(downloadedCoverPath!); + } + if (_hasValue(extractedCoverPath)) { + await _cleanupTempFileAndParent(extractedCoverPath!); + } + if (_hasValue(tempPath)) { + await _safeDeleteFile(tempPath!); + } + + return ffmpegResult != null; + } + + Future _reEnrichLocalTrack(LocalLibraryItem item) async { + final durationMs = (item.duration ?? 0) * 1000; + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': true, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? item.artistName, + 'track_number': item.trackNumber ?? 0, + 'disc_number': item.discNumber ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'label': '', + 'copyright': '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + if (method == 'native') { + return true; + } + if (method == 'ffmpeg') { + return _applyFfmpegReEnrichResult(item, result); + } + return false; + } + + /// Batch re-enrich selected local tracks + Future _reEnrichSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item != null) { + selected.add(item); + } + } + + if (selected.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.trackReEnrich), + content: Text( + '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' + '${context.l10n.downloadedAlbumSelectedCount(selected.length)}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackReEnrich), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + var successCount = 0; + final total = selected.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + final item = selected[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final ok = await _reEnrichLocalTrack(item); + if (ok) { + successCount++; + } + } catch (_) {} + } + + if (!mounted) { + return; + } + + final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + try { + if (localLibraryPath.isNotEmpty && + !ref.read(localLibraryProvider).isScanning) { + await ref + .read(localLibraryProvider.notifier) + .startScan(localLibraryPath); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + _exitSelectionMode(); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); } /// Show batch convert bottom sheet @@ -899,7 +1102,9 @@ class _LocalAlbumScreenState extends ConsumerState { width: 40, height: 4, decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), borderRadius: BorderRadius.circular(2), ), ), @@ -931,8 +1136,9 @@ class _LocalAlbumScreenState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - selectedBitrate = - format == 'Opus' ? '128k' : '320k'; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; }); } }, @@ -982,7 +1188,9 @@ class _LocalAlbumScreenState extends ConsumerState { ), ), child: Text( - context.l10n.selectionConvertCount(_selectedIds.length), + context.l10n.selectionConvertCount( + _selectedIds.length, + ), ), ), ), @@ -1050,7 +1258,9 @@ class _LocalAlbumScreenState extends ConsumerState { title: Text(context.l10n.selectionBatchConvertConfirmTitle), content: Text( context.l10n.selectionBatchConvertConfirmMessage( - selected.length, targetFormat, bitrate, + selected.length, + targetFormat, + bitrate, ), ), actions: [ @@ -1079,7 +1289,9 @@ class _LocalAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.selectionBatchConvertProgress(i + 1, total)), + content: Text( + context.l10n.selectionBatchConvertProgress(i + 1, total), + ), duration: const Duration(seconds: 30), ), ); @@ -1108,7 +1320,8 @@ class _LocalAlbumScreenState extends ConsumerState { final coverOutput = '${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; final coverResult = await PlatformBridge.extractCoverToFile( - item.filePath, coverOutput, + item.filePath, + coverOutput, ); if (coverResult['error'] == null) coverPath = coverOutput; } catch (_) {} @@ -1119,7 +1332,9 @@ class _LocalAlbumScreenState extends ConsumerState { if (isSaf) { // Copy SAF file to temp for conversion - safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath); + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); if (safTempPath == null) continue; workingPath = safTempPath; } @@ -1134,12 +1349,16 @@ class _LocalAlbumScreenState extends ConsumerState { ); if (coverPath != null) { - try { await File(coverPath).delete(); } catch (_) {} + try { + await File(coverPath).delete(); + } catch (_) {} } if (newPath == null) { if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } @@ -1165,7 +1384,8 @@ class _LocalAlbumScreenState extends ConsumerState { final docIdx = pathSegments.indexOf('document'); if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { final treeId = pathSegments[treeIdx + 1]; - treeUri = 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; } if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { @@ -1179,9 +1399,13 @@ class _LocalAlbumScreenState extends ConsumerState { : ''; if (treeId.isNotEmpty && docPath.startsWith(treeId)) { final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree; + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : ''; + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; } } else { oldFileName = docPath; @@ -1190,10 +1414,16 @@ class _LocalAlbumScreenState extends ConsumerState { if (treeUri != null && oldFileName.isNotEmpty) { final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -1204,22 +1434,32 @@ class _LocalAlbumScreenState extends ConsumerState { ); if (safUri == null || safUri.isEmpty) { - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } // Delete old SAF file - try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} await localDb.deleteByPath(item.filePath); } // Clean up temp files - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } } else { // Regular file: just remove old entry, rescan will find the new one @@ -1239,7 +1479,11 @@ class _LocalAlbumScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat), + context.l10n.selectionBatchConvertSuccess( + successCount, + total, + targetFormat, + ), ), ), ); @@ -1298,7 +1542,9 @@ class _LocalAlbumScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.downloadedAlbumSelectedCount(selectedCount), + context.l10n.downloadedAlbumSelectedCount( + selectedCount, + ), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), @@ -1337,15 +1583,15 @@ class _LocalAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Action buttons row: Share, Convert + // Action buttons row: Re-enrich, Convert Row( children: [ Expanded( child: _LocalAlbumSelectionActionButton( - icon: Icons.share_outlined, - label: context.l10n.selectionShareCount(selectedCount), + icon: Icons.auto_fix_high_outlined, + label: '${context.l10n.trackReEnrich} ($selectedCount)', onPressed: selectedCount > 0 - ? () => _shareSelected(tracks) + ? () => _reEnrichSelected(tracks) : null, colorScheme: colorScheme, ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 7f1b40f4..6dc317f7 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -79,7 +79,9 @@ class UnifiedLibraryItem { // Lossy format with bitrate final fmt = item.format?.toUpperCase() ?? ''; quality = '$fmt ${item.bitrate}kbps'.trim(); - } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { + } else if (item.bitDepth != null && + item.bitDepth! > 0 && + item.sampleRate != null) { // Lossless format with actual bit depth quality = '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; @@ -910,7 +912,9 @@ class _QueueTabState extends ConsumerState { if (item.bitrate != null && item.bitrate! > 0) { return '${item.bitrate}kbps'; } - if (item.bitDepth == null || item.bitDepth == 0 || item.sampleRate == null) { + if (item.bitDepth == null || + item.bitDepth == 0 || + item.sampleRate == null) { return null; } return '${item.bitDepth}bit/${(item.sampleRate! / 1000).toStringAsFixed(1)}kHz'; @@ -2927,6 +2931,263 @@ class _QueueTabState extends ConsumerState { ); } + bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty; + + List _selectedItemsFromAll( + List allItems, + ) { + final itemsById = {for (final item in allItems) item.id: item}; + return _selectedIds + .map((id) => itemsById[id]) + .whereType() + .toList(growable: false); + } + + bool _isLocalOnlySelection(List allItems) { + final selectedItems = _selectedItemsFromAll(allItems); + return selectedItems.isNotEmpty && + selectedItems.every((item) => item.localItem != null); + } + + Future _safeDeleteTempFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + } + + Future _cleanupTempFileAndParentDir(String path) async { + await _safeDeleteTempFile(path); + try { + final parent = File(path).parent; + if (await parent.exists()) { + await parent.delete(); + } + } catch (_) {} + } + + Future _applyQueueFfmpegReEnrichResult( + LocalLibraryItem item, + Map result, + ) async { + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath; + final downloadedCoverPath = result['cover_path'] as String?; + String? effectiveCoverPath = downloadedCoverPath; + String? extractedCoverPath; + + if (!_hasTextValue(effectiveCoverPath)) { + try { + final tempDir = await Directory.systemTemp.createTemp( + 'reenrich_cover_', + ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final extracted = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); + if (extracted['error'] == null) { + effectiveCoverPath = coverOutput; + extractedCoverPath = coverOutput; + } else { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); + + final format = item.format?.toLowerCase(); + final lowerPath = item.filePath.toLowerCase(); + final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); + final isOpus = + format == 'opus' || + format == 'ogg' || + lowerPath.endsWith('.opus') || + lowerPath.endsWith('.ogg'); + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: effectiveCoverPath, + metadata: metadata, + ); + } + + if (ffmpegResult != null && + _hasTextValue(tempPath) && + _hasTextValue(safUri)) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!); + if (!ok) { + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + await _safeDeleteTempFile(tempPath!); + return false; + } + } + + if (_hasTextValue(downloadedCoverPath)) { + await _safeDeleteTempFile(downloadedCoverPath!); + } + if (_hasTextValue(extractedCoverPath)) { + await _cleanupTempFileAndParentDir(extractedCoverPath!); + } + if (_hasTextValue(tempPath)) { + await _safeDeleteTempFile(tempPath!); + } + + return ffmpegResult != null; + } + + Future _reEnrichQueueLocalTrack(LocalLibraryItem item) async { + final durationMs = (item.duration ?? 0) * 1000; + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': true, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? item.artistName, + 'track_number': item.trackNumber ?? 0, + 'disc_number': item.discNumber ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'label': '', + 'copyright': '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + if (method == 'native') { + return true; + } + if (method == 'ffmpeg') { + return _applyQueueFfmpegReEnrichResult(item, result); + } + return false; + } + + Future _reEnrichSelectedLocalFromQueue( + List allItems, + ) async { + final selectedItems = _selectedItemsFromAll(allItems); + final selectedLocalItems = selectedItems + .map((item) => item.localItem) + .whereType() + .toList(growable: false); + + if (selectedLocalItems.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.trackReEnrich), + content: Text( + '${context.l10n.trackReEnrichOnlineSubtitle}\n\n' + '${context.l10n.downloadedAlbumSelectedCount(selectedLocalItems.length)}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.trackReEnrich), + ), + ], + ), + ); + + if (confirmed != true || !mounted) { + return; + } + + var successCount = 0; + final total = selectedLocalItems.length; + + for (var i = 0; i < total; i++) { + if (!mounted) break; + final item = selectedLocalItems[i]; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', + ), + duration: const Duration(seconds: 30), + ), + ); + + try { + final ok = await _reEnrichQueueLocalTrack(item); + if (ok) { + successCount++; + } + } catch (_) {} + } + + if (!mounted) { + return; + } + + final localLibraryPath = ref.read(settingsProvider).localLibraryPath.trim(); + try { + if (localLibraryPath.isNotEmpty && + !ref.read(localLibraryProvider).isScanning) { + await ref + .read(localLibraryProvider.notifier) + .startScan(localLibraryPath); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + _exitSelectionMode(); + + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : '${context.l10n.trackReEnrichSuccess} ($successCount/$total) • Failed: $failedCount'; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(summary))); + } + /// Share selected tracks via system share sheet Future _shareSelected(List allItems) async { final itemsById = {for (final item in allItems) item.id: item}; @@ -2966,9 +3227,7 @@ class _QueueTabState extends ConsumerState { // Share regular files via SharePlus if (filesToShare.isNotEmpty) { - await SharePlus.instance.share( - ShareParams(files: filesToShare), - ); + await SharePlus.instance.share(ShareParams(files: filesToShare)); } } @@ -3038,8 +3297,9 @@ class _QueueTabState extends ConsumerState { if (selected) { setSheetState(() { selectedFormat = format; - selectedBitrate = - format == 'Opus' ? '128k' : '320k'; + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; }); } }, @@ -3132,10 +3392,10 @@ class _QueueTabState extends ConsumerState { final ext = nameToCheck.endsWith('.flac') ? 'FLAC' : nameToCheck.endsWith('.mp3') - ? 'MP3' - : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) - ? 'Opus' - : null; + ? 'MP3' + : (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg')) + ? 'Opus' + : null; if (ext != null && ext != targetFormat) { selectedItems.add(item); } @@ -3144,9 +3404,7 @@ class _QueueTabState extends ConsumerState { if (selectedItems.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.selectionConvertNoConvertible), - ), + SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)), ); } return; @@ -3182,6 +3440,8 @@ class _QueueTabState extends ConsumerState { int successCount = 0; final total = selectedItems.length; final historyDb = HistoryDatabase.instance; + final newQuality = + '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; for (int i = 0; i < total; i++) { if (!mounted) break; @@ -3205,8 +3465,7 @@ class _QueueTabState extends ConsumerState { 'ALBUM': item.albumName, }; try { - final result = - await PlatformBridge.readFileMetadata(item.filePath); + final result = await PlatformBridge.readFileMetadata(item.filePath); if (result['error'] == null) { result.forEach((key, value) { if (key == 'error' || value == null) return; @@ -3238,8 +3497,9 @@ class _QueueTabState extends ConsumerState { String? safTempPath; if (isSaf) { - safTempPath = - await PlatformBridge.copyContentUriToTemp(item.filePath); + safTempPath = await PlatformBridge.copyContentUriToTemp( + item.filePath, + ); if (safTempPath == null) continue; workingPath = safTempPath; } @@ -3278,10 +3538,12 @@ class _QueueTabState extends ConsumerState { if (treeUri != null && treeUri.isNotEmpty) { final oldFileName = hi.safFileName ?? ''; final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = - dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = - targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' @@ -3317,6 +3579,8 @@ class _QueueTabState extends ConsumerState { hi.id, safUri, newSafFileName: newFileName, + newQuality: newQuality, + clearAudioSpecs: true, ); } // Cleanup temp files @@ -3341,7 +3605,8 @@ class _QueueTabState extends ConsumerState { final docIdx = pathSegments.indexOf('document'); if (treeIdx >= 0 && treeIdx + 1 < pathSegments.length) { final treeId = pathSegments[treeIdx + 1]; - treeUri = 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; + treeUri = + 'content://${uri.authority}/tree/${Uri.encodeComponent(treeId)}'; } if (docIdx >= 0 && docIdx + 1 < pathSegments.length) { final docPath = Uri.decodeFull(pathSegments[docIdx + 1]); @@ -3353,9 +3618,13 @@ class _QueueTabState extends ConsumerState { : ''; if (treeId.isNotEmpty && docPath.startsWith(treeId)) { final afterTree = docPath.substring(treeId.length); - final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree; + final trimmed = afterTree.startsWith('/') + ? afterTree.substring(1) + : afterTree; final lastSlash = trimmed.lastIndexOf('/'); - relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : ''; + relativeDir = lastSlash >= 0 + ? trimmed.substring(0, lastSlash) + : ''; } } else { oldFileName = docPath; @@ -3364,10 +3633,16 @@ class _QueueTabState extends ConsumerState { if (treeUri != null && oldFileName.isNotEmpty) { final dotIdx = oldFileName.lastIndexOf('.'); - final baseName = dotIdx > 0 ? oldFileName.substring(0, dotIdx) : oldFileName; - final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' + ? '.opus' + : '.mp3'; final newFileName = '$baseName$newExt'; - final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; final safUri = await PlatformBridge.createSafFileFromPath( treeUri: treeUri, @@ -3378,27 +3653,39 @@ class _QueueTabState extends ConsumerState { ); if (safUri == null || safUri.isEmpty) { - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } continue; } - try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} + try { + await PlatformBridge.safDelete(item.filePath); + } catch (_) {} await LibraryDatabase.instance.deleteByPath(item.filePath); } // Cleanup temp files - try { await File(newPath).delete(); } catch (_) {} + try { + await File(newPath).delete(); + } catch (_) {} if (safTempPath != null) { - try { await File(safTempPath).delete(); } catch (_) {} + try { + await File(safTempPath).delete(); + } catch (_) {} } } else if (item.historyItem != null) { // Regular file - update history path await historyDb.updateFilePath( item.historyItem!.id, newPath, + newQuality: newQuality, + clearAudioSpecs: true, ); } else if (item.localItem != null) { // Regular local library file - delete old db entry, rescan picks up new file @@ -3443,6 +3730,7 @@ class _QueueTabState extends ConsumerState { final selectedCount = _selectedIds.length; final allSelected = selectedCount == unifiedItems.length && unifiedItems.isNotEmpty; + final localOnlySelection = _isLocalOnlySelection(unifiedItems); return Container( decoration: BoxDecoration( @@ -3526,15 +3814,21 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), - // Action buttons row: Share, Convert, Delete + // Action buttons row: Share/Re-enrich, Convert, Delete Row( children: [ Expanded( child: _SelectionActionButton( - icon: Icons.share_outlined, - label: context.l10n.selectionShareCount(selectedCount), + icon: localOnlySelection + ? Icons.auto_fix_high_outlined + : Icons.share_outlined, + label: localOnlySelection + ? '${context.l10n.trackReEnrich} ($selectedCount)' + : context.l10n.selectionShareCount(selectedCount), onPressed: selectedCount > 0 - ? () => _shareSelected(unifiedItems) + ? () => localOnlySelection + ? _reEnrichSelectedLocalFromQueue(unifiedItems) + : _shareSelected(unifiedItems) : null, colorScheme: colorScheme, ), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 412f8701..44944d49 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -174,7 +174,6 @@ class _RecentDonorsCard extends StatelessWidget { 'laflame', 'Elias el Autentico', 'Faylyne', - 'Jul', ]; // Match SettingsGroup color logic @@ -363,7 +362,8 @@ int _cr(String v) { for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } return r; } -const _cv = {998370}; +// Highlighted supporters (hashes of names): Julian, J. +const _cv = {1825257268, 1035}; class _SupporterChip extends StatelessWidget { final String name; @@ -374,15 +374,19 @@ class _SupporterChip extends StatelessWidget { @override Widget build(BuildContext context) { final e = _cv.contains(_cr(name)); + const goldChipColor = Color(0xFFFFF8DC); + const goldAccentColor = Color(0xFFB8860B); + const goldDarkChipColor = Color(0xFF3A3000); + final chipColor = e - ? const Color(0xFFFFF3E0) + ? goldChipColor : colorScheme.secondaryContainer; final accentColor = e - ? const Color(0xFFFF8F00) + ? goldAccentColor : colorScheme.primary; final isDark = Theme.of(context).brightness == Brightness.dark; final effectiveChipColor = e && isDark - ? const Color(0xFF3E2723) + ? goldDarkChipColor : chipColor; return Material(