mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-20 07:04:49 +02:00
feat: batch re-enrich for local tracks, SAF FD refactor, Ogg quality fix
- Replace batch Share action with batch Re-enrich in local album selection bar
- Full native/FFmpeg re-enrich flow with SAF write-back support
- Triggers incremental local library scan after completion to refresh metadata
- Queue tab: switch first selection action to Re-enrich when all selected items are local-only
- Refactor SAF FD handoff in MainActivity: drop detachFd/dup pattern, pass procfs
path to Go and let Go re-open it to avoid fdsan double-close race conditions
- Handle /proc/self/fd/ path in output_fd.go: re-open via O_WRONLY|O_TRUNC instead
of taking raw FD ownership
- Fix Ogg duration/bitrate calculation in audio_metadata.go:
- Use float64 arithmetic and math.Round for accurate duration
- Compute bitrate from file size / float duration at the source
- Validate Ogg page header fields (version, headerType, segment table) to avoid
false positives from payload bytes during backward scan
- Guard against corrupted granule values (>24h duration, <8kbps bitrate)
- Rename trackReEnrich label from 'Re-enrich Metadata' to 'Re-enrich' across all
13 locales and ARB files
- Update CHANGELOG.md with 3.7.0 entry
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1019,7 +1019,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate =
|
||||
format == 'Opus' ? '128k' : '320k';
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1102,7 +1105,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1127,12 +1132,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
);
|
||||
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
);
|
||||
|
||||
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<DownloadedAlbumScreen> {
|
||||
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<DownloadedAlbumScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||
context.l10n.downloadedAlbumSelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
|
||||
@@ -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<LocalAlbumScreen> {
|
||||
}
|
||||
|
||||
// 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<LocalAlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Share selected local tracks
|
||||
Future<void> _shareSelected(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final safUris = <String>[];
|
||||
final filesToShare = <XFile>[];
|
||||
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<void> _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<void> _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<bool> _applyFfmpegReEnrichResult(
|
||||
LocalLibraryItem item,
|
||||
Map<String, dynamic> 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<String, dynamic>?)?.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<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final request = <String, dynamic>{
|
||||
'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<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final selected = <LocalLibraryItem>[];
|
||||
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item != null) {
|
||||
selected.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate =
|
||||
format == 'Opus' ? '128k' : '320k';
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -982,7 +1188,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.selectionConvertCount(_selectedIds.length),
|
||||
context.l10n.selectionConvertCount(
|
||||
_selectedIds.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1050,7 +1258,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
|
||||
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<LocalAlbumScreen> {
|
||||
);
|
||||
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
: '';
|
||||
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<LocalAlbumScreen> {
|
||||
|
||||
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<LocalAlbumScreen> {
|
||||
);
|
||||
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
),
|
||||
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,
|
||||
),
|
||||
|
||||
+331
-37
@@ -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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty;
|
||||
|
||||
List<UnifiedLibraryItem> _selectedItemsFromAll(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
return _selectedIds
|
||||
.map((id) => itemsById[id])
|
||||
.whereType<UnifiedLibraryItem>()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool _isLocalOnlySelection(List<UnifiedLibraryItem> allItems) {
|
||||
final selectedItems = _selectedItemsFromAll(allItems);
|
||||
return selectedItems.isNotEmpty &&
|
||||
selectedItems.every((item) => item.localItem != null);
|
||||
}
|
||||
|
||||
Future<void> _safeDeleteTempFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _cleanupTempFileAndParentDir(String path) async {
|
||||
await _safeDeleteTempFile(path);
|
||||
try {
|
||||
final parent = File(path).parent;
|
||||
if (await parent.exists()) {
|
||||
await parent.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> _applyQueueFfmpegReEnrichResult(
|
||||
LocalLibraryItem item,
|
||||
Map<String, dynamic> 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<String, dynamic>?)?.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<bool> _reEnrichQueueLocalTrack(LocalLibraryItem item) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final request = <String, dynamic>{
|
||||
'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<void> _reEnrichSelectedLocalFromQueue(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) async {
|
||||
final selectedItems = _selectedItemsFromAll(allItems);
|
||||
final selectedLocalItems = selectedItems
|
||||
.map((item) => item.localItem)
|
||||
.whereType<LocalLibraryItem>()
|
||||
.toList(growable: false);
|
||||
|
||||
if (selectedLocalItems.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _shareSelected(List<UnifiedLibraryItem> allItems) async {
|
||||
final itemsById = {for (final item in allItems) item.id: item};
|
||||
@@ -2966,9 +3227,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
|
||||
// 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<QueueTab> {
|
||||
if (selected) {
|
||||
setSheetState(() {
|
||||
selectedFormat = format;
|
||||
selectedBitrate =
|
||||
format == 'Opus' ? '128k' : '320k';
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -3132,10 +3392,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
'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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
hi.id,
|
||||
safUri,
|
||||
newSafFileName: newFileName,
|
||||
newQuality: newQuality,
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
}
|
||||
// Cleanup temp files
|
||||
@@ -3341,7 +3605,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
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<QueueTab> {
|
||||
: '';
|
||||
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<QueueTab> {
|
||||
|
||||
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<QueueTab> {
|
||||
);
|
||||
|
||||
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<QueueTab> {
|
||||
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<QueueTab> {
|
||||
|
||||
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,
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user