mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-05-31 19:05:05 +07:00
feat: add multi-select share and batch convert in downloaded/local album screens
- Add shareMultipleContentUris native handler in MainActivity for ACTION_SEND_MULTIPLE - Add shareMultipleContentUris binding in PlatformBridge - Add _shareSelected and _performBatchConversion methods to DownloadedAlbumScreen and LocalAlbumScreen - Add batch convert bottom sheet UI with format/bitrate selection (MP3/Opus, 128k-320k) - Add share & convert action buttons to selection bottom bar in both screens - Add batch convert with full SAF support: temp copy, write-back, history update - Add share/convert selection strings to l10n (all supported locales + app_en.arb) - Add queue tab selection share/convert feature (queue_tab.dart) - Update donate page - Update go.sum with bumped dependency hashes
This commit is contained in:
parent
cdc5836785
commit
5605930aef
23 changed files with 2512 additions and 23 deletions
20
.gitattributes
vendored
Normal file
20
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows scripts
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.jar binary
|
||||||
|
*.aar binary
|
||||||
|
*.keystore binary
|
||||||
|
*.jks binary
|
||||||
|
|
@ -1546,6 +1546,28 @@ class MainActivity: FlutterFragmentActivity() {
|
||||||
result.error("share_failed", e.message, null)
|
result.error("share_failed", e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"shareMultipleContentUris" -> {
|
||||||
|
val uriStrings = call.argument<List<String>>("uris") ?: emptyList()
|
||||||
|
val title = call.argument<String>("title") ?: ""
|
||||||
|
try {
|
||||||
|
val uris = ArrayList<Uri>(uriStrings.size)
|
||||||
|
for (s in uriStrings) {
|
||||||
|
uris.add(Uri.parse(s))
|
||||||
|
}
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||||
|
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
setType("audio/*")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(shareIntent, title.ifBlank { "Share" }))
|
||||||
|
result.success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("share_failed", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
"fetchLyrics" -> {
|
"fetchLyrics" -> {
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
|
|
@ -28,20 +30,36 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||||
|
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||||
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||||
|
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
|
|
||||||
|
|
@ -5257,6 +5257,58 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Conversion failed'**
|
/// **'Conversion failed'**
|
||||||
String get trackConvertFailed;
|
String get trackConvertFailed;
|
||||||
|
|
||||||
|
/// Share button text with count in selection mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Share {count} {count, plural, =1{track} other{tracks}}'**
|
||||||
|
String selectionShareCount(int count);
|
||||||
|
|
||||||
|
/// Snackbar when no selected files exist on disk
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No shareable files found'**
|
||||||
|
String get selectionShareNoFiles;
|
||||||
|
|
||||||
|
/// Convert button text with count in selection mode
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert {count} {count, plural, =1{track} other{tracks}}'**
|
||||||
|
String selectionConvertCount(int count);
|
||||||
|
|
||||||
|
/// Snackbar when no selected tracks support conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No convertible tracks selected'**
|
||||||
|
String get selectionConvertNoConvertible;
|
||||||
|
|
||||||
|
/// Confirmation dialog title for batch conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Batch Convert'**
|
||||||
|
String get selectionBatchConvertConfirmTitle;
|
||||||
|
|
||||||
|
/// Confirmation dialog message for batch conversion
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.'**
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Snackbar during batch conversion progress
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converting {current} of {total}...'**
|
||||||
|
String selectionBatchConvertProgress(int current, int total);
|
||||||
|
|
||||||
|
/// Snackbar after batch conversion completes
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Converted {success} of {total} tracks to {format}'**
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2987,4 +2987,60 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2966,4 +2966,60 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2966,6 +2966,62 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
|
|
|
||||||
|
|
@ -2972,4 +2972,60 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2966,4 +2966,60 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2979,4 +2979,60 @@ class AppLocalizationsId extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2952,4 +2952,60 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2965,4 +2965,60 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2966,4 +2966,60 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2966,6 +2966,62 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
|
|
|
||||||
|
|
@ -3064,4 +3064,60 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2981,4 +2981,60 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2966,6 +2966,62 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFailed => 'Conversion failed';
|
String get trackConvertFailed => 'Conversion failed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionShareCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Share $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionShareNoFiles => 'No shareable files found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionConvertCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessage(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
|
return 'Converting $current of $total...';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||||
|
return 'Converted $success of $total tracks to $format';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for Chinese, as used in China (`zh_CN`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
|
|
|
||||||
|
|
@ -2258,5 +2258,52 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"trackConvertFailed": "Conversion failed",
|
"trackConvertFailed": "Conversion failed",
|
||||||
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
|
"@trackConvertFailed": {"description": "Snackbar when conversion fails"},
|
||||||
|
|
||||||
|
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionShareCount": {
|
||||||
|
"description": "Share button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionShareNoFiles": "No shareable files found",
|
||||||
|
"@selectionShareNoFiles": {"description": "Snackbar when no selected files exist on disk"},
|
||||||
|
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@selectionConvertCount": {
|
||||||
|
"description": "Convert button text with count in selection mode",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||||
|
"@selectionConvertNoConvertible": {"description": "Snackbar when no selected tracks support conversion"},
|
||||||
|
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||||
|
"@selectionBatchConvertConfirmTitle": {"description": "Confirmation dialog title for batch conversion"},
|
||||||
|
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message for batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"},
|
||||||
|
"format": {"type": "String"},
|
||||||
|
"bitrate": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||||
|
"@selectionBatchConvertProgress": {
|
||||||
|
"description": "Snackbar during batch conversion progress",
|
||||||
|
"placeholders": {
|
||||||
|
"current": {"type": "int"},
|
||||||
|
"total": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||||
|
"@selectionBatchConvertSuccess": {
|
||||||
|
"description": "Snackbar after batch conversion completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {"type": "int"},
|
||||||
|
"total": {"type": "int"},
|
||||||
|
"format": {"type": "String"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,12 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/services/history_database.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
|
@ -939,6 +944,364 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Share selected tracks via system share sheet
|
||||||
|
Future<void> _shareSelected(List<DownloadHistoryItem> allTracks) async {
|
||||||
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
|
final safUris = <String>[];
|
||||||
|
final filesToShare = <XFile>[];
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safUris.isEmpty && filesToShare.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.selectionShareNoFiles)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share SAF content URIs via native intent
|
||||||
|
if (safUris.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
if (safUris.length == 1) {
|
||||||
|
await PlatformBridge.shareContentUri(safUris.first);
|
||||||
|
} else {
|
||||||
|
await PlatformBridge.shareMultipleContentUris(safUris);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share regular files via SharePlus
|
||||||
|
if (filesToShare.isNotEmpty) {
|
||||||
|
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show batch convert bottom sheet
|
||||||
|
void _showBatchConvertSheet(
|
||||||
|
BuildContext context,
|
||||||
|
List<DownloadHistoryItem> allTracks,
|
||||||
|
) {
|
||||||
|
String selectedFormat = 'MP3';
|
||||||
|
String selectedBitrate = '320k';
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final formats = ['MP3', 'Opus'];
|
||||||
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
context.l10n.selectionBatchConvertConfirmTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
context.l10n.trackConvertTargetFormat,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: formats.map((format) {
|
||||||
|
final isSelected = format == selectedFormat;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(format),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedFormat = format;
|
||||||
|
selectedBitrate =
|
||||||
|
format == 'Opus' ? '128k' : '320k';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
context.l10n.trackConvertBitrate,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: bitrates.map((br) {
|
||||||
|
final isSelected = br == selectedBitrate;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(br),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setSheetState(() => selectedBitrate = br);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_performBatchConversion(
|
||||||
|
allTracks: allTracks,
|
||||||
|
targetFormat: selectedFormat,
|
||||||
|
bitrate: selectedBitrate,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.selectionConvertCount(_selectedIds.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performBatchConversion({
|
||||||
|
required List<DownloadHistoryItem> allTracks,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
}) async {
|
||||||
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
|
final selected = <DownloadHistoryItem>[];
|
||||||
|
for (final id in _selectedIds) {
|
||||||
|
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)
|
||||||
|
? item.safFileName!.toLowerCase()
|
||||||
|
: item.filePath.toLowerCase();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
selected.length, targetFormat, bitrate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(context.l10n.trackConvertFormat),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
int successCount = 0;
|
||||||
|
final total = selected.length;
|
||||||
|
final historyDb = HistoryDatabase.instance;
|
||||||
|
|
||||||
|
for (int 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.selectionBatchConvertProgress(i + 1, total)),
|
||||||
|
duration: const Duration(seconds: 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final metadata = <String, String>{
|
||||||
|
'TITLE': item.trackName,
|
||||||
|
'ARTIST': item.artistName,
|
||||||
|
'ALBUM': item.albumName,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||||
|
if (result['error'] == null) {
|
||||||
|
result.forEach((key, value) {
|
||||||
|
if (key == 'error' || value == null) return;
|
||||||
|
final v = value.toString().trim();
|
||||||
|
if (v.isEmpty) return;
|
||||||
|
metadata[key.toUpperCase()] = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
String? coverPath;
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final coverOutput =
|
||||||
|
'${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
||||||
|
item.filePath, coverOutput,
|
||||||
|
);
|
||||||
|
if (coverResult['error'] == null) coverPath = coverOutput;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
String workingPath = item.filePath;
|
||||||
|
final isSaf = isContentUri(item.filePath);
|
||||||
|
String? safTempPath;
|
||||||
|
|
||||||
|
if (isSaf) {
|
||||||
|
safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath);
|
||||||
|
if (safTempPath == null) continue;
|
||||||
|
workingPath = safTempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newPath = await FFmpegService.convertAudioFormat(
|
||||||
|
inputPath: workingPath,
|
||||||
|
targetFormat: targetFormat.toLowerCase(),
|
||||||
|
bitrate: bitrate,
|
||||||
|
metadata: metadata,
|
||||||
|
coverPath: coverPath,
|
||||||
|
deleteOriginal: !isSaf,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (coverPath != null) {
|
||||||
|
try { await File(coverPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPath == null) {
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSaf) {
|
||||||
|
final treeUri = item.downloadTreeUri;
|
||||||
|
final relativeDir = item.safRelativeDir ?? '';
|
||||||
|
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 newFileName = '$baseName$newExt';
|
||||||
|
final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg';
|
||||||
|
|
||||||
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
|
treeUri: treeUri,
|
||||||
|
relativeDir: relativeDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: mimeType,
|
||||||
|
srcPath: newPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (safUri == null || safUri.isEmpty) {
|
||||||
|
try { await File(newPath).delete(); } catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try { await PlatformBridge.safDelete(item.filePath); } catch (_) {}
|
||||||
|
await historyDb.updateFilePath(item.id, safUri, newSafFileName: newFileName);
|
||||||
|
}
|
||||||
|
try { await File(newPath).delete(); } catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await historyDb.updateFilePath(item.id, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
|
_exitSelectionMode();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSelectionBottomBar(
|
Widget _buildSelectionBottomBar(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
|
@ -991,9 +1354,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.l10n.downloadedAlbumSelectedCount(
|
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||||
selectedCount,
|
|
||||||
),
|
|
||||||
style: Theme.of(context).textTheme.titleMedium
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
|
|
@ -1030,7 +1391,36 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Action buttons row: Share, Convert
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _DownloadedAlbumSelectionActionButton(
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
label: context.l10n.selectionShareCount(selectedCount),
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _shareSelected(tracks)
|
||||||
|
: null,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _DownloadedAlbumSelectionActionButton(
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
label: context.l10n.selectionConvertCount(selectedCount),
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _showBatchConvertSheet(context, tracks)
|
||||||
|
: null,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
|
|
@ -1064,3 +1454,62 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DownloadedAlbumSelectionActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
const _DownloadedAlbumSelectionActionButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.colorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDisabled = onPressed == null;
|
||||||
|
return Material(
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@ import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
|
|
||||||
/// Screen to display tracks from a local library album
|
/// Screen to display tracks from a local library album
|
||||||
|
|
@ -820,6 +824,428 @@ 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>[];
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safUris.isEmpty && filesToShare.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.selectionShareNoFiles)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share SAF content URIs via native intent
|
||||||
|
if (safUris.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
if (safUris.length == 1) {
|
||||||
|
await PlatformBridge.shareContentUri(safUris.first);
|
||||||
|
} else {
|
||||||
|
await PlatformBridge.shareMultipleContentUris(safUris);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share regular files via SharePlus
|
||||||
|
if (filesToShare.isNotEmpty) {
|
||||||
|
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show batch convert bottom sheet
|
||||||
|
void _showBatchConvertSheet(
|
||||||
|
BuildContext context,
|
||||||
|
List<LocalLibraryItem> allTracks,
|
||||||
|
) {
|
||||||
|
String selectedFormat = 'MP3';
|
||||||
|
String selectedBitrate = '320k';
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final formats = ['MP3', 'Opus'];
|
||||||
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
context.l10n.selectionBatchConvertConfirmTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
context.l10n.trackConvertTargetFormat,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: formats.map((format) {
|
||||||
|
final isSelected = format == selectedFormat;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(format),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedFormat = format;
|
||||||
|
selectedBitrate =
|
||||||
|
format == 'Opus' ? '128k' : '320k';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
context.l10n.trackConvertBitrate,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: bitrates.map((br) {
|
||||||
|
final isSelected = br == selectedBitrate;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(br),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setSheetState(() => selectedBitrate = br);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_performBatchConversion(
|
||||||
|
allTracks: allTracks,
|
||||||
|
targetFormat: selectedFormat,
|
||||||
|
bitrate: selectedBitrate,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.selectionConvertCount(_selectedIds.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performBatchConversion({
|
||||||
|
required List<LocalLibraryItem> allTracks,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
}) 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) continue;
|
||||||
|
// Detect current format: prefer item.format field (works for SAF too),
|
||||||
|
// fall back to file extension for regular paths
|
||||||
|
String? currentFormat;
|
||||||
|
if (item.format != null && item.format!.isNotEmpty) {
|
||||||
|
final fmt = item.format!.toLowerCase();
|
||||||
|
if (fmt == 'flac') {
|
||||||
|
currentFormat = 'FLAC';
|
||||||
|
} else if (fmt == 'mp3') {
|
||||||
|
currentFormat = 'MP3';
|
||||||
|
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||||
|
currentFormat = 'Opus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentFormat == null) {
|
||||||
|
// Fallback: try file extension (works for regular paths)
|
||||||
|
final lower = item.filePath.toLowerCase();
|
||||||
|
if (lower.endsWith('.flac')) {
|
||||||
|
currentFormat = 'FLAC';
|
||||||
|
} else if (lower.endsWith('.mp3')) {
|
||||||
|
currentFormat = 'MP3';
|
||||||
|
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||||
|
currentFormat = 'Opus';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentFormat != null && currentFormat != targetFormat) {
|
||||||
|
selected.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.selectionConvertNoConvertible)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
selected.length, targetFormat, bitrate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(context.l10n.trackConvertFormat),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
int successCount = 0;
|
||||||
|
final total = selected.length;
|
||||||
|
final localDb = LibraryDatabase.instance;
|
||||||
|
|
||||||
|
for (int 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.selectionBatchConvertProgress(i + 1, total)),
|
||||||
|
duration: const Duration(seconds: 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final metadata = <String, String>{
|
||||||
|
'TITLE': item.trackName,
|
||||||
|
'ARTIST': item.artistName,
|
||||||
|
'ALBUM': item.albumName,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||||
|
if (result['error'] == null) {
|
||||||
|
result.forEach((key, value) {
|
||||||
|
if (key == 'error' || value == null) return;
|
||||||
|
final v = value.toString().trim();
|
||||||
|
if (v.isEmpty) return;
|
||||||
|
metadata[key.toUpperCase()] = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
String? coverPath;
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final coverOutput =
|
||||||
|
'${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
||||||
|
item.filePath, coverOutput,
|
||||||
|
);
|
||||||
|
if (coverResult['error'] == null) coverPath = coverOutput;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final isSaf = isContentUri(item.filePath);
|
||||||
|
String workingPath = item.filePath;
|
||||||
|
String? safTempPath;
|
||||||
|
|
||||||
|
if (isSaf) {
|
||||||
|
// Copy SAF file to temp for conversion
|
||||||
|
safTempPath = await PlatformBridge.copyContentUriToTemp(item.filePath);
|
||||||
|
if (safTempPath == null) continue;
|
||||||
|
workingPath = safTempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newPath = await FFmpegService.convertAudioFormat(
|
||||||
|
inputPath: workingPath,
|
||||||
|
targetFormat: targetFormat.toLowerCase(),
|
||||||
|
bitrate: bitrate,
|
||||||
|
metadata: metadata,
|
||||||
|
coverPath: coverPath,
|
||||||
|
deleteOriginal: !isSaf, // Only delete original for regular files
|
||||||
|
);
|
||||||
|
|
||||||
|
if (coverPath != null) {
|
||||||
|
try { await File(coverPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPath == null) {
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSaf) {
|
||||||
|
// For SAF: derive the parent tree URI and relative dir from the content URI,
|
||||||
|
// then create new SAF file and delete old one
|
||||||
|
//
|
||||||
|
// Parse the SAF URI to get the tree document path:
|
||||||
|
// content://...tree/...document/.../oldName.flac
|
||||||
|
// We need tree URI and relative dir to create the new file
|
||||||
|
final uri = Uri.parse(item.filePath);
|
||||||
|
final pathSegments = uri.pathSegments;
|
||||||
|
|
||||||
|
// Try to find 'tree' and 'document' segments
|
||||||
|
String? treeUri;
|
||||||
|
String relativeDir = '';
|
||||||
|
String oldFileName = '';
|
||||||
|
|
||||||
|
// Typical SAF document URI pattern:
|
||||||
|
// content://authority/tree/<tree-id>/document/<doc-path>
|
||||||
|
final treeIdx = pathSegments.indexOf('tree');
|
||||||
|
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)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docIdx >= 0 && docIdx + 1 < pathSegments.length) {
|
||||||
|
final docPath = Uri.decodeFull(pathSegments[docIdx + 1]);
|
||||||
|
final slashIdx = docPath.lastIndexOf('/');
|
||||||
|
if (slashIdx >= 0) {
|
||||||
|
oldFileName = docPath.substring(slashIdx + 1);
|
||||||
|
// Relative dir is everything after the tree id's directory base
|
||||||
|
final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length
|
||||||
|
? Uri.decodeFull(pathSegments[treeIdx + 1])
|
||||||
|
: '';
|
||||||
|
if (treeId.isNotEmpty && docPath.startsWith(treeId)) {
|
||||||
|
final afterTree = docPath.substring(treeId.length);
|
||||||
|
final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree;
|
||||||
|
final lastSlash = trimmed.lastIndexOf('/');
|
||||||
|
relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oldFileName = docPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 newFileName = '$baseName$newExt';
|
||||||
|
final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg';
|
||||||
|
|
||||||
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
|
treeUri: treeUri,
|
||||||
|
relativeDir: relativeDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: mimeType,
|
||||||
|
srcPath: newPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (safUri == null || safUri.isEmpty) {
|
||||||
|
try { await File(newPath).delete(); } catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old SAF file
|
||||||
|
try { await PlatformBridge.safDelete(item.filePath); } catch (_) {}
|
||||||
|
await localDb.deleteByPath(item.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp files
|
||||||
|
try { await File(newPath).delete(); } catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular file: just remove old entry, rescan will find the new one
|
||||||
|
await localDb.deleteByPath(item.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload local library to pick up converted files
|
||||||
|
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
|
_exitSelectionMode();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertSuccess(successCount, total, targetFormat),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSelectionBottomBar(
|
Widget _buildSelectionBottomBar(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
|
|
@ -872,9 +1298,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.l10n.downloadedAlbumSelectedCount(
|
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||||
selectedCount,
|
|
||||||
),
|
|
||||||
style: Theme.of(context).textTheme.titleMedium
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
|
|
@ -911,7 +1335,36 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Action buttons row: Share, Convert
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _LocalAlbumSelectionActionButton(
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
label: context.l10n.selectionShareCount(selectedCount),
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _shareSelected(tracks)
|
||||||
|
: null,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _LocalAlbumSelectionActionButton(
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
label: context.l10n.selectionConvertCount(selectedCount),
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _showBatchConvertSheet(context, tracks)
|
||||||
|
: null,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
|
|
@ -945,3 +1398,62 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _LocalAlbumSelectionActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
const _LocalAlbumSelectionActionButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.colorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDisabled = onPressed == null;
|
||||||
|
return Material(
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
|
|
@ -14,6 +18,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
|
import 'package:spotiflac_android/services/history_database.dart';
|
||||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||||
|
|
@ -2922,6 +2927,512 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Share selected tracks via system share sheet
|
||||||
|
Future<void> _shareSelected(List<UnifiedLibraryItem> allItems) async {
|
||||||
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
|
final safUris = <String>[];
|
||||||
|
final filesToShare = <XFile>[];
|
||||||
|
|
||||||
|
for (final id in _selectedIds) {
|
||||||
|
final item = itemsById[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safUris.isEmpty && filesToShare.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.selectionShareNoFiles)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share SAF content URIs via native intent
|
||||||
|
if (safUris.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
if (safUris.length == 1) {
|
||||||
|
await PlatformBridge.shareContentUri(safUris.first);
|
||||||
|
} else {
|
||||||
|
await PlatformBridge.shareMultipleContentUris(safUris);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share regular files via SharePlus
|
||||||
|
if (filesToShare.isNotEmpty) {
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(files: filesToShare),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show batch convert bottom sheet for selected tracks
|
||||||
|
void _showBatchConvertSheet(
|
||||||
|
BuildContext context,
|
||||||
|
List<UnifiedLibraryItem> allItems,
|
||||||
|
) {
|
||||||
|
String selectedFormat = 'MP3';
|
||||||
|
String selectedBitrate = '320k';
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final formats = ['MP3', 'Opus'];
|
||||||
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.4,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
context.l10n.selectionBatchConvertConfirmTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
context.l10n.trackConvertTargetFormat,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: formats.map((format) {
|
||||||
|
final isSelected = format == selectedFormat;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: ChoiceChip(
|
||||||
|
label: Text(format),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setSheetState(() {
|
||||||
|
selectedFormat = format;
|
||||||
|
selectedBitrate =
|
||||||
|
format == 'Opus' ? '128k' : '320k';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
context.l10n.trackConvertBitrate,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: bitrates.map((br) {
|
||||||
|
final isSelected = br == selectedBitrate;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(br),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setSheetState(() => selectedBitrate = br);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_performBatchConversion(
|
||||||
|
allItems: allItems,
|
||||||
|
targetFormat: selectedFormat,
|
||||||
|
bitrate: selectedBitrate,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.selectionConvertCount(
|
||||||
|
_selectedIds.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform batch conversion on selected tracks
|
||||||
|
Future<void> _performBatchConversion({
|
||||||
|
required List<UnifiedLibraryItem> allItems,
|
||||||
|
required String targetFormat,
|
||||||
|
required String bitrate,
|
||||||
|
}) async {
|
||||||
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
|
final selectedItems = <UnifiedLibraryItem>[];
|
||||||
|
for (final id in _selectedIds) {
|
||||||
|
final item = itemsById[id];
|
||||||
|
if (item == null) continue;
|
||||||
|
// Detect format: use safFileName for download history SAF items,
|
||||||
|
// item.localItem?.format for local library items, file extension as fallback
|
||||||
|
String nameToCheck;
|
||||||
|
if (item.historyItem?.safFileName != null &&
|
||||||
|
item.historyItem!.safFileName!.isNotEmpty) {
|
||||||
|
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
||||||
|
} else if (item.localItem?.format != null &&
|
||||||
|
item.localItem!.format!.isNotEmpty) {
|
||||||
|
// Synthesize a fake extension to keep detection unified
|
||||||
|
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
||||||
|
} else {
|
||||||
|
nameToCheck = item.filePath.toLowerCase();
|
||||||
|
}
|
||||||
|
final ext = nameToCheck.endsWith('.flac')
|
||||||
|
? 'FLAC'
|
||||||
|
: nameToCheck.endsWith('.mp3')
|
||||||
|
? 'MP3'
|
||||||
|
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||||
|
? 'Opus'
|
||||||
|
: null;
|
||||||
|
if (ext != null && ext != targetFormat) {
|
||||||
|
selectedItems.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedItems.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.selectionConvertNoConvertible),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
selectedItems.length,
|
||||||
|
targetFormat,
|
||||||
|
bitrate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(context.l10n.dialogCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(context.l10n.trackConvertFormat),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
int successCount = 0;
|
||||||
|
final total = selectedItems.length;
|
||||||
|
final historyDb = HistoryDatabase.instance;
|
||||||
|
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
if (!mounted) break;
|
||||||
|
final item = selectedItems[i];
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertProgress(i + 1, total),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 30),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read metadata from file
|
||||||
|
final metadata = <String, String>{
|
||||||
|
'TITLE': item.trackName,
|
||||||
|
'ARTIST': item.artistName,
|
||||||
|
'ALBUM': item.albumName,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
final result =
|
||||||
|
await PlatformBridge.readFileMetadata(item.filePath);
|
||||||
|
if (result['error'] == null) {
|
||||||
|
result.forEach((key, value) {
|
||||||
|
if (key == 'error' || value == null) return;
|
||||||
|
final v = value.toString().trim();
|
||||||
|
if (v.isEmpty) return;
|
||||||
|
metadata[key.toUpperCase()] = v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Extract cover art
|
||||||
|
String? coverPath;
|
||||||
|
try {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final coverOutput =
|
||||||
|
'${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
final coverResult = await PlatformBridge.extractCoverToFile(
|
||||||
|
item.filePath,
|
||||||
|
coverOutput,
|
||||||
|
);
|
||||||
|
if (coverResult['error'] == null) {
|
||||||
|
coverPath = coverOutput;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Handle SAF vs regular file
|
||||||
|
String workingPath = item.filePath;
|
||||||
|
final isSaf = isContentUri(item.filePath);
|
||||||
|
String? safTempPath;
|
||||||
|
|
||||||
|
if (isSaf) {
|
||||||
|
safTempPath =
|
||||||
|
await PlatformBridge.copyContentUriToTemp(item.filePath);
|
||||||
|
if (safTempPath == null) continue;
|
||||||
|
workingPath = safTempPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert
|
||||||
|
final newPath = await FFmpegService.convertAudioFormat(
|
||||||
|
inputPath: workingPath,
|
||||||
|
targetFormat: targetFormat.toLowerCase(),
|
||||||
|
bitrate: bitrate,
|
||||||
|
metadata: metadata,
|
||||||
|
coverPath: coverPath,
|
||||||
|
deleteOriginal: !isSaf,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup cover temp
|
||||||
|
if (coverPath != null) {
|
||||||
|
try {
|
||||||
|
await File(coverPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPath == null) {
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SAF write-back
|
||||||
|
if (isSaf && item.historyItem != null) {
|
||||||
|
final hi = item.historyItem!;
|
||||||
|
final treeUri = hi.downloadTreeUri;
|
||||||
|
final relativeDir = hi.safRelativeDir ?? '';
|
||||||
|
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 newFileName = '$baseName$newExt';
|
||||||
|
final mimeType = targetFormat.toLowerCase() == 'opus'
|
||||||
|
? 'audio/opus'
|
||||||
|
: 'audio/mpeg';
|
||||||
|
|
||||||
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
|
treeUri: treeUri,
|
||||||
|
relativeDir: relativeDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: mimeType,
|
||||||
|
srcPath: newPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (safUri == null || safUri.isEmpty) {
|
||||||
|
try {
|
||||||
|
await File(newPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old SAF file
|
||||||
|
try {
|
||||||
|
await PlatformBridge.safDelete(item.filePath);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Update history
|
||||||
|
await historyDb.updateFilePath(
|
||||||
|
hi.id,
|
||||||
|
safUri,
|
||||||
|
newSafFileName: newFileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Cleanup temp files
|
||||||
|
try {
|
||||||
|
await File(newPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try {
|
||||||
|
await File(safTempPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} else if (isSaf && item.localItem != null) {
|
||||||
|
// Local library SAF item: parse content URI to derive tree and dir
|
||||||
|
final uri = Uri.parse(item.filePath);
|
||||||
|
final pathSegments = uri.pathSegments;
|
||||||
|
|
||||||
|
String? treeUri;
|
||||||
|
String relativeDir = '';
|
||||||
|
String oldFileName = '';
|
||||||
|
|
||||||
|
final treeIdx = pathSegments.indexOf('tree');
|
||||||
|
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)}';
|
||||||
|
}
|
||||||
|
if (docIdx >= 0 && docIdx + 1 < pathSegments.length) {
|
||||||
|
final docPath = Uri.decodeFull(pathSegments[docIdx + 1]);
|
||||||
|
final slashIdx = docPath.lastIndexOf('/');
|
||||||
|
if (slashIdx >= 0) {
|
||||||
|
oldFileName = docPath.substring(slashIdx + 1);
|
||||||
|
final treeId = treeIdx >= 0 && treeIdx + 1 < pathSegments.length
|
||||||
|
? Uri.decodeFull(pathSegments[treeIdx + 1])
|
||||||
|
: '';
|
||||||
|
if (treeId.isNotEmpty && docPath.startsWith(treeId)) {
|
||||||
|
final afterTree = docPath.substring(treeId.length);
|
||||||
|
final trimmed = afterTree.startsWith('/') ? afterTree.substring(1) : afterTree;
|
||||||
|
final lastSlash = trimmed.lastIndexOf('/');
|
||||||
|
relativeDir = lastSlash >= 0 ? trimmed.substring(0, lastSlash) : '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oldFileName = docPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 newFileName = '$baseName$newExt';
|
||||||
|
final mimeType = targetFormat.toLowerCase() == 'opus' ? 'audio/opus' : 'audio/mpeg';
|
||||||
|
|
||||||
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
|
treeUri: treeUri,
|
||||||
|
relativeDir: relativeDir,
|
||||||
|
fileName: newFileName,
|
||||||
|
mimeType: mimeType,
|
||||||
|
srcPath: newPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (safUri == null || safUri.isEmpty) {
|
||||||
|
try { await File(newPath).delete(); } catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try { await PlatformBridge.safDelete(item.filePath); } catch (_) {}
|
||||||
|
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup temp files
|
||||||
|
try { await File(newPath).delete(); } catch (_) {}
|
||||||
|
if (safTempPath != null) {
|
||||||
|
try { await File(safTempPath).delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
} else if (item.historyItem != null) {
|
||||||
|
// Regular file - update history path
|
||||||
|
await historyDb.updateFilePath(
|
||||||
|
item.historyItem!.id,
|
||||||
|
newPath,
|
||||||
|
);
|
||||||
|
} else if (item.localItem != null) {
|
||||||
|
// Regular local library file - delete old db entry, rescan picks up new file
|
||||||
|
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount++;
|
||||||
|
} catch (_) {
|
||||||
|
// Continue to next item on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload history and local library to reflect path changes in UI
|
||||||
|
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||||
|
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||||
|
|
||||||
|
_exitSelectionMode();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.selectionBatchConvertSuccess(
|
||||||
|
successCount,
|
||||||
|
total,
|
||||||
|
targetFormat,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bottom action bar for selection mode (Material Design 3 style)
|
/// Bottom action bar for selection mode (Material Design 3 style)
|
||||||
Widget _buildSelectionBottomBar(
|
Widget _buildSelectionBottomBar(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
|
@ -3013,7 +3524,36 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Action buttons row: Share, Convert, Delete
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SelectionActionButton(
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
label: context.l10n.selectionShareCount(selectedCount),
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _shareSelected(unifiedItems)
|
||||||
|
: null,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _SelectionActionButton(
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
label: context.l10n.selectionConvertCount(selectedCount),
|
||||||
|
onPressed: selectedCount > 0
|
||||||
|
? () => _showBatchConvertSheet(context, unifiedItems)
|
||||||
|
: null,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -3971,3 +4511,63 @@ class _FilterChip extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reusable action button for selection mode bottom bar
|
||||||
|
class _SelectionActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
const _SelectionActionButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.colorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDisabled = onPressed == null;
|
||||||
|
return Material(
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 18,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDisabled
|
||||||
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||||
'laflame',
|
'laflame',
|
||||||
'Elias el Autentico',
|
'Elias el Autentico',
|
||||||
'Faylyne',
|
'Faylyne',
|
||||||
|
'Jul',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Match SettingsGroup color logic
|
// Match SettingsGroup color logic
|
||||||
|
|
@ -357,6 +358,13 @@ class _DonateCardItem extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _cr(String v) {
|
||||||
|
int r = 0x1F;
|
||||||
|
for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
const _cv = {998370};
|
||||||
|
|
||||||
class _SupporterChip extends StatelessWidget {
|
class _SupporterChip extends StatelessWidget {
|
||||||
final String name;
|
final String name;
|
||||||
final ColorScheme colorScheme;
|
final ColorScheme colorScheme;
|
||||||
|
|
@ -365,32 +373,57 @@ class _SupporterChip extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final e = _cv.contains(_cr(name));
|
||||||
|
final chipColor = e
|
||||||
|
? const Color(0xFFFFF3E0)
|
||||||
|
: colorScheme.secondaryContainer;
|
||||||
|
final accentColor = e
|
||||||
|
? const Color(0xFFFF8F00)
|
||||||
|
: colorScheme.primary;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final effectiveChipColor = e && isDark
|
||||||
|
? const Color(0xFF3E2723)
|
||||||
|
: chipColor;
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: colorScheme.secondaryContainer,
|
color: effectiveChipColor,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Padding(
|
child: Container(
|
||||||
|
decoration: e
|
||||||
|
? BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: accentColor.withValues(alpha: 0.4),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 10,
|
radius: 10,
|
||||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||||
child: Text(
|
child: e
|
||||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||||
style: TextStyle(
|
: Text(
|
||||||
fontSize: 10,
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
color: colorScheme.primary,
|
fontSize: 10,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
color: accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color: colorScheme.onSecondaryContainer,
|
color: e
|
||||||
fontWeight: FontWeight.w500,
|
? accentColor
|
||||||
|
: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,14 @@ class PlatformBridge {
|
||||||
return result as bool? ?? false;
|
return result as bool? ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<bool> shareMultipleContentUris(List<String> uris, {String title = ''}) async {
|
||||||
|
final result = await _channel.invokeMethod('shareMultipleContentUris', {
|
||||||
|
'uris': uris,
|
||||||
|
'title': title,
|
||||||
|
});
|
||||||
|
return result as bool? ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> fetchLyrics(
|
static Future<Map<String, dynamic>> fetchLyrics(
|
||||||
String spotifyId,
|
String spotifyId,
|
||||||
String trackName,
|
String trackName,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue