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:
zarzet
2026-02-18 19:29:59 +07:00
parent 5605930aef
commit 4df96db809
36 changed files with 843 additions and 192 deletions
+28
View File
@@ -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) {}
+47 -16
View File
@@ -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
}
// =============================================================================
+7
View File
@@ -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)
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -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 =>
+3 -3
View File
@@ -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 =>
+1 -1
View 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"
},
+1 -1
View 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"},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+1 -1
View 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"
},
+81 -26
View 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),
),
+304 -58
View File
@@ -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
View File
@@ -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,
),
+9 -5
View File
@@ -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(