mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +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)
|
||||
}
|
||||
}
|
||||
"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" -> {
|
||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||
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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
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/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
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/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/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/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/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
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/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/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/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/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
|
|
|
|||
|
|
@ -5257,6 +5257,58 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Conversion failed'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2987,4 +2987,60 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
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
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -2972,4 +2972,60 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -3064,4 +3064,60 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
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
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -2258,5 +2258,52 @@
|
|||
}
|
||||
},
|
||||
"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_riverpod/flutter_riverpod.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/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/utils/file_access.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(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
|
|
@ -991,9 +1354,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadedAlbumSelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
|
|
@ -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(
|
||||
width: double.infinity,
|
||||
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/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.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';
|
||||
|
||||
/// 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(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
|
|
@ -872,9 +1298,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadedAlbumSelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
|
|
@ -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(
|
||||
width: double.infinity,
|
||||
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_riverpod/flutter_riverpod.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/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.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/local_library_provider.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/screens/track_metadata_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)
|
||||
Widget _buildSelectionBottomBar(
|
||||
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(
|
||||
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',
|
||||
'Elias el Autentico',
|
||||
'Faylyne',
|
||||
'Jul',
|
||||
];
|
||||
|
||||
// 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 {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
|
|
@ -365,32 +373,57 @@ class _SupporterChip extends StatelessWidget {
|
|||
|
||||
@override
|
||||
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(
|
||||
color: colorScheme.secondaryContainer,
|
||||
color: effectiveChipColor,
|
||||
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),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: e
|
||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||
: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: e
|
||||
? accentColor
|
||||
: colorScheme.onSecondaryContainer,
|
||||
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -244,6 +244,14 @@ class PlatformBridge {
|
|||
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(
|
||||
String spotifyId,
|
||||
String trackName,
|
||||
|
|
|
|||
Loading…
Reference in a new issue