diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d080aebf --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 16e7ffbb..be542a83 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1546,6 +1546,28 @@ class MainActivity: FlutterFragmentActivity() { result.error("share_failed", e.message, null) } } + "shareMultipleContentUris" -> { + val uriStrings = call.argument>("uris") ?: emptyList() + val title = call.argument("title") ?: "" + try { + val uris = ArrayList(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("spotify_id") ?: "" val trackName = call.argument("track_name") ?: "" diff --git a/go_backend/go.sum b/go_backend/go.sum index 3b71ae9b..50e29433 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -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= diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 40a8f4f7..195b2f38 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d7a5349b..51d9f9b2 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8225f5c6..444ac354 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index f854b116..bf82005a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 566fdcc1..b5f1a283 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 44e45c90..d18df768 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index f8b81ecc..a5205b83 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8284e093..82fd113e 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a80520d2..f26d0895 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 151a8805..397122f8 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 89eaf853..442e8f1b 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d38edcc9..e3d6a1d8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 1706ce88..0c14cf80 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 04d3eba1..2d4a94c4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 148fbd13..7a6dd20c 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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"} + } + } } diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 4f1270f3..8faa85a7 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -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 { ); } + /// Share selected tracks via system share sheet + Future _shareSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final safUris = []; + final filesToShare = []; + + 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 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 _performBatchConversion({ + required List allTracks, + required String targetFormat, + required String bitrate, + }) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) 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( + 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 = { + '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 { 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 { ), ], ), - 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 { ); } } + +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, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index a86bc2aa..5d654943 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -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 { ); } + /// Share selected local tracks + Future _shareSelected(List allTracks) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final safUris = []; + final filesToShare = []; + + 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 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 _performBatchConversion({ + required List allTracks, + required String targetFormat, + required String bitrate, + }) async { + final tracksById = {for (final t in allTracks) t.id: t}; + final selected = []; + for (final id in _selectedIds) { + final item = tracksById[id]; + if (item == null) 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( + 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 = { + '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//document/ + 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 { 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 { ), ], ), - 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 { ); } } + +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, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4327516c..7f1b40f4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { ); } + /// Share selected tracks via system share sheet + Future _shareSelected(List allItems) async { + final itemsById = {for (final item in allItems) item.id: item}; + final safUris = []; + final filesToShare = []; + + 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 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 _performBatchConversion({ + required List allItems, + required String targetFormat, + required String bitrate, + }) async { + final itemsById = {for (final item in allItems) item.id: item}; + final selectedItems = []; + 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( + 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 = { + '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 { ], ), - 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, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 4470be2e..412f8701 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -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, ), ), ], diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f2351aeb..9b25456f 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -244,6 +244,14 @@ class PlatformBridge { return result as bool? ?? false; } + static Future shareMultipleContentUris(List uris, {String title = ''}) async { + final result = await _channel.invokeMethod('shareMultipleContentUris', { + 'uris': uris, + 'title': title, + }); + return result as bool? ?? false; + } + static Future> fetchLyrics( String spotifyId, String trackName,