Compare commits

...

12 Commits

Author SHA1 Message Date
zarzet 2b8ec744dd feat(extensions): embed full metadata and cover after extension downloads
Replace duplicate genre/label-only embedding with a shared post-download
step that writes complete FLAC tags and optional cover art when the output
file is available locally.
2026-07-02 01:24:22 +07:00
zarzet 5f11f5b114 chore(release): bump version to 4.7.1+137 2026-07-02 01:24:22 +07:00
zarzet 61f62363b3 fix(convert): make convert bottom sheets draggable and scroll-controlled
Use DraggableScrollableSheet for single and batch convert flows and open
convert sheets with isScrollControlled so long option lists remain usable
on smaller screens.
2026-07-02 01:24:21 +07:00
zarzet 3278e32711 fix(extensions): default verification browser to in-app first
Prefer the in-app browser for signed-session verification challenges,
normalize invalid saved modes to the new default, and keep the help
dialog modal until the user explicitly dismisses it.
2026-07-02 01:24:21 +07:00
zarzet 0be6455d46 fix(download): hand off native worker verification to interactive queue
Detect verification-required failures from the Android native worker,
cancel the worker, and route the item back through the interactive
verification flow with the correct service identifier.
2026-07-02 01:24:21 +07:00
zarzet 0bf5a39a92 fix(ac4): reject truncated AC-4 sample entries safely
Validate audio sample entry header bounds before QuickTime v1
normalization and dac4 injection so malformed MP4 trees are left
unchanged or rejected instead of panicking on truncated boxes.
2026-07-02 01:24:21 +07:00
zarzet 5424648158 feat(audio): add dither and resampler options for lossless conversion
Let users choose FFmpeg dithering when reducing bit depth and SoXr or
SWR resampling when changing sample rate across single-track and batch
lossless conversion flows.
2026-07-02 01:24:20 +07:00
zarzet dcfd95f276 feat(extensions): manual verification help when browser launch fails
Expose a root navigator for global dialogs, show a fallback help sheet
with copy and reopen actions when verification URLs cannot launch, and
schedule the same prompt after a timeout during pending grants.
2026-07-02 01:24:20 +07:00
zarzet 4d6f7d8b08 l10n: add extension verification and lossless processing strings
Add localization keys for verification browser settings, manual
verification help dialog actions, and lossless conversion dithering and
resampler option labels.
2026-07-02 01:24:20 +07:00
zarzet 2c2cf8cdf8 fix(extensions): bootstrap and clear pending signed-session auth
Ensure pending auth requests are created when verification is needed but
missing, and clear them after signed-session grant completion or clear
so stale challenges do not block later verification flows.
2026-07-02 01:24:19 +07:00
zarzet 08c738dc69 feat(settings): add extension verification browser mode preference
Let users choose whether signed-session verification opens in the
external browser or in-app browser first, with automatic fallback to
the other mode when launch fails.
2026-07-02 01:24:19 +07:00
github-actions[bot] eb36b0bb7b chore: update AltStore source to v4.7.0 2026-06-30 20:59:56 +00:00
42 changed files with 2641 additions and 554 deletions
+4 -4
View File
@@ -7,12 +7,12 @@
"name": "SpotiFLAC Mobile",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "4.6.0",
"versionDate": "2026-06-13",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-ios-unsigned.ipa",
"version": "4.7.0",
"versionDate": "2026-06-30",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.0/SpotiFLAC-v4.7.0-ios-unsigned.ipa",
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 34347687
"size": 37442462
}
]
}
+19 -9
View File
@@ -87,22 +87,26 @@ func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool
}
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
// entry header (from the box body start) before child boxes begin.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) int64 {
// entry header (from the box body start) before child boxes begin. ok is false
// for malformed/truncated entries whose declared header is not fully present.
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
base := entry.body()
if base+10 > entry.end() {
return 8 + 20
return 0, false
}
version := binary.BigEndian.Uint16(data[base+8 : base+10])
hdrLen = 8 + 20
switch version {
case 1:
return 8 + 20 + 16
hdrLen += 16
case 2:
return 8 + 20 + 36
default:
return 8 + 20
hdrLen += 36
}
if base+hdrLen > entry.end() {
return 0, false
}
return hdrLen, true
}
type ac4Location struct {
@@ -232,13 +236,16 @@ func normalizeQuickTimeAudioToMP4(data []byte) []byte {
return data // already v0 (or v2, left untouched)
}
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
extStart := entry.body() + 8 + 20
extEnd := extStart + 16
if extEnd > entry.end() {
return data
}
delta := int64(-16)
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
for _, b := range loc.chain {
growBoxSize(data, b, delta)
@@ -273,7 +280,10 @@ func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
return nil
}
hdrLen := audioSampleEntryHeaderLen(dst, loc.entry)
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
if !ok {
return fmt.Errorf("malformed ac-4 sample entry")
}
childStart := loc.entry.body() + hdrLen
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
// Already has dac4; still persist any normalization changes.
+76
View File
@@ -0,0 +1,76 @@
package gobackend
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func mp4TestBox(typ string, body []byte) []byte {
out := make([]byte, 8+len(body))
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
copy(out[4:8], typ)
copy(out[8:], body)
return out
}
func mp4TestAC4Tree(entryBody []byte) []byte {
entry := mp4TestBox("ac-4", entryBody)
stsdBody := append([]byte{
0, 0, 0, 0, // version/flags
0, 0, 0, 1, // entry_count
}, entry...)
stsd := mp4TestBox("stsd", stsdBody)
stbl := mp4TestBox("stbl", stsd)
minf := mp4TestBox("minf", stbl)
mdia := mp4TestBox("mdia", minf)
trak := mp4TestBox("trak", mdia)
moov := mp4TestBox("moov", trak)
return moov
}
func shortAC4SampleEntryBody(version uint16) []byte {
body := make([]byte, 10)
binary.BigEndian.PutUint16(body[8:10], version)
return body
}
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
defer func() {
if r := recover(); r != nil {
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
}
}()
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
if !bytes.Equal(got, input) {
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
}
}
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
dir := t.TempDir()
decryptedPath := filepath.Join(dir, "decrypted.mp4")
sourcePath := filepath.Join(dir, "source.mp4")
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
t.Fatal(err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
}
}()
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
t.Fatal("expected malformed AC-4 sample entry error")
}
}
+35 -1
View File
@@ -3287,7 +3287,7 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
}
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
req := GetPendingAuthRequest(extensionID)
req := ensureExtensionPendingAuthRequest(extensionID)
if req == nil {
return "", nil
}
@@ -3306,6 +3306,40 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
return string(jsonBytes), nil
}
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
extensionID = strings.TrimSpace(extensionID)
if extensionID == "" {
return nil
}
if req := GetPendingAuthRequest(extensionID); req != nil {
return req
}
manager := getExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
return nil
}
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
return nil
}
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
if config.Namespace == "" || config.BaseURL == "" {
return nil
}
if record, err := ext.runtime.loadSignedSession(config); err == nil {
record.SessionID = ""
record.SessionSecret = ""
record.ExpiresAt = ""
_ = ext.runtime.saveSignedSession(config, record)
}
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
return GetPendingAuthRequest(extensionID)
}
func SetExtensionAuthCodeByID(extensionID, authCode string) {
SetExtensionAuthCode(extensionID, authCode)
}
+74 -18
View File
@@ -2602,15 +2602,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
resp.Composer = req.Composer
}
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
embedExtensionDownloadMetadata(resp, req, alreadyExists)
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
@@ -2808,15 +2800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
applyExtensionRequestFallbacks(&resp, req)
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
}
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
}
embedExtensionDownloadMetadata(resp, req, alreadyExists)
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
indexISRC := strings.TrimSpace(resp.ISRC)
@@ -3008,6 +2992,78 @@ func canEmbedGenreLabel(filePath string) bool {
return err == nil && !info.IsDir() && info.Size() > 0
}
func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) {
if alreadyExists || !req.EmbedMetadata {
return
}
filePath := strings.TrimSpace(resp.FilePath)
if !canEmbedGenreLabel(filePath) {
if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" {
GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath)
}
return
}
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
var coverData []byte
if coverURL != "" {
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
if err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
} else if len(data) > 0 {
coverData = data
}
}
metadata := Metadata{
Title: firstNonEmptyTrimmed(resp.Title, req.TrackName),
Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName),
Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName),
AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist),
ArtistTagMode: req.ArtistTagMode,
Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate),
TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber),
TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks),
DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber),
TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs),
ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC),
Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre),
Label: firstNonEmptyTrimmed(resp.Label, req.Label),
Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright),
Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer),
}
if req.EmbedLyrics {
metadata.Lyrics = resp.LyricsLRC
}
var err error
if len(coverData) > 0 {
err = EmbedMetadataWithCoverData(filePath, metadata, coverData)
} else {
err = EmbedMetadata(filePath, metadata, "")
}
if err != nil {
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err)
return
}
if len(coverData) > 0 {
GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL)
} else {
GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n")
}
}
func firstPositiveInt(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
return p.customSearch(query, options, "", "")
}
+2
View File
@@ -236,6 +236,7 @@ func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value
if err := r.saveSignedSession(config, record); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
@@ -256,6 +257,7 @@ func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) go
if err := r.exchangeSignedSessionGrant(grant); err != nil {
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
}
ClearPendingAuthRequest(r.extensionID)
return r.vm.ToValue(map[string]interface{}{"success": true})
}
+2
View File
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
@@ -28,6 +29,7 @@ final _routerProvider = Provider<GoRouter>((ref) {
}
return GoRouter(
navigatorKey: AppNavigationService.rootNavigatorKey,
initialLocation: initialLocation,
routes: [
GoRoute(path: '/', builder: (context, state) => const MainShell()),
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
class AppInfo {
static const String version = '4.7.0';
static const String buildNumber = '136';
static const String version = '4.7.1';
static const String buildNumber = '137';
static const String fullVersion = '$version+$buildNumber';
static String get displayVersion => kDebugMode ? 'Internal' : version;
+120
View File
@@ -7607,6 +7607,48 @@ abstract class AppLocalizations {
/// **'Lossless'**
String get trackConvertLosslessSuffix;
/// Section label for lossless conversion dithering options
///
/// In en, this message translates to:
/// **'Dithering'**
String get trackConvertDithering;
/// Section label for lossless conversion resampler options
///
/// In en, this message translates to:
/// **'Resampler'**
String get trackConvertResampler;
/// Lossless conversion dither option with no dithering applied
///
/// In en, this message translates to:
/// **'None'**
String get trackConvertDitherNone;
/// Lossless conversion triangular probability density function dither option
///
/// In en, this message translates to:
/// **'TPDF'**
String get trackConvertDitherTriangular;
/// Lossless conversion high-pass triangular dither option
///
/// In en, this message translates to:
/// **'Triangular HP'**
String get trackConvertDitherTriangularHp;
/// Lossless conversion default FFmpeg swresample resampler option
///
/// In en, this message translates to:
/// **'SWR'**
String get trackConvertResamplerSwr;
/// Lossless conversion SoX resampler option
///
/// In en, this message translates to:
/// **'SoXr'**
String get trackConvertResamplerSoxr;
/// Fallback changelog text when release notes cannot be parsed
///
/// In en, this message translates to:
@@ -7750,6 +7792,84 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Kosovo'**
String get regionCountryXK;
/// Settings option title for extension verification browser preference
///
/// In en, this message translates to:
/// **'Verification browser'**
String get extensionVerificationBrowserTitle;
/// Subtitle when external browser is preferred for extension verification
///
/// In en, this message translates to:
/// **'Open challenges in the default browser first'**
String get extensionVerificationBrowserSubtitleExternal;
/// Subtitle when in-app browser is preferred for extension verification
///
/// In en, this message translates to:
/// **'Open challenges in the in-app browser first'**
String get extensionVerificationBrowserSubtitleInApp;
/// Chip label for external browser verification mode
///
/// In en, this message translates to:
/// **'External'**
String get extensionVerificationBrowserExternal;
/// Chip label for in-app browser verification mode
///
/// In en, this message translates to:
/// **'In-app'**
String get extensionVerificationBrowserInApp;
/// Dialog title when automatic browser launch for verification fails
///
/// In en, this message translates to:
/// **'Open verification manually'**
String get extensionVerificationHelpTitleManual;
/// Dialog title when verification is taking longer than expected
///
/// In en, this message translates to:
/// **'Verification still waiting'**
String get extensionVerificationHelpTitleWaiting;
/// Dialog message when automatic browser launch for verification fails
///
/// In en, this message translates to:
/// **'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.'**
String get extensionVerificationHelpMessageManual;
/// Dialog message when verification may need manual browser help
///
/// In en, this message translates to:
/// **'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.'**
String get extensionVerificationHelpMessageWaiting;
/// Button to dismiss the extension verification help dialog
///
/// In en, this message translates to:
/// **'Close'**
String get extensionVerificationClose;
/// Button to copy the extension verification URL
///
/// In en, this message translates to:
/// **'Copy link'**
String get extensionVerificationCopyLink;
/// Snackbar after copying the extension verification URL
///
/// In en, this message translates to:
/// **'Verification link copied'**
String get extensionVerificationLinkCopied;
/// Button to open the extension verification URL in a browser
///
/// In en, this message translates to:
/// **'Open browser'**
String get extensionVerificationOpenBrowser;
}
class _AppLocalizationsDelegate
+66
View File
@@ -4625,6 +4625,27 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4704,4 +4725,49 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4674,6 +4674,27 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4753,4 +4774,49 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4625,6 +4625,27 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4704,4 +4725,49 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4619,6 +4619,27 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4698,6 +4719,51 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+66
View File
@@ -4739,6 +4739,27 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4818,4 +4839,49 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4625,6 +4625,27 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4704,4 +4725,49 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4609,6 +4609,27 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'Tidak ada';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'Lihat catatan rilis untuk detail.';
@@ -4688,4 +4709,49 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Browser verifikasi';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Buka tantangan di browser default terlebih dahulu';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Buka tantangan di browser dalam aplikasi terlebih dahulu';
@override
String get extensionVerificationBrowserExternal => 'Eksternal';
@override
String get extensionVerificationBrowserInApp => 'Dalam aplikasi';
@override
String get extensionVerificationHelpTitleManual =>
'Buka verifikasi secara manual';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verifikasi masih menunggu';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile tidak dapat membuka browser secara otomatis. Buka tautan ini di browser Anda, atau salin secara manual.';
@override
String get extensionVerificationHelpMessageWaiting =>
'Jika browser tidak terbuka, atau verifikasi selesai tetapi tidak kembali ke SpotiFLAC Mobile, buka tautan ini lagi atau salin secara manual.';
@override
String get extensionVerificationClose => 'Tutup';
@override
String get extensionVerificationCopyLink => 'Salin tautan';
@override
String get extensionVerificationLinkCopied => 'Tautan verifikasi disalin';
@override
String get extensionVerificationOpenBrowser => 'Buka browser';
}
+66
View File
@@ -4612,6 +4612,27 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4691,4 +4712,49 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4610,6 +4610,27 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4689,4 +4710,49 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4625,6 +4625,27 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4704,4 +4725,49 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4619,6 +4619,27 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4698,6 +4719,51 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+66
View File
@@ -4681,6 +4681,27 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4760,4 +4781,49 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4656,6 +4656,27 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4735,4 +4756,49 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4678,6 +4678,27 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4757,4 +4778,49 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
+66
View File
@@ -4619,6 +4619,27 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackConvertLosslessSuffix => 'Lossless';
@override
String get trackConvertDithering => 'Dithering';
@override
String get trackConvertResampler => 'Resampler';
@override
String get trackConvertDitherNone => 'None';
@override
String get trackConvertDitherTriangular => 'TPDF';
@override
String get trackConvertDitherTriangularHp => 'Triangular HP';
@override
String get trackConvertResamplerSwr => 'SWR';
@override
String get trackConvertResamplerSoxr => 'SoXr';
@override
String get updateSeeReleaseNotes => 'See release notes for details.';
@@ -4698,6 +4719,51 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get regionCountryXK => 'Kosovo';
@override
String get extensionVerificationBrowserTitle => 'Verification browser';
@override
String get extensionVerificationBrowserSubtitleExternal =>
'Open challenges in the default browser first';
@override
String get extensionVerificationBrowserSubtitleInApp =>
'Open challenges in the in-app browser first';
@override
String get extensionVerificationBrowserExternal => 'External';
@override
String get extensionVerificationBrowserInApp => 'In-app';
@override
String get extensionVerificationHelpTitleManual =>
'Open verification manually';
@override
String get extensionVerificationHelpTitleWaiting =>
'Verification still waiting';
@override
String get extensionVerificationHelpMessageManual =>
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
@override
String get extensionVerificationHelpMessageWaiting =>
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
@override
String get extensionVerificationClose => 'Close';
@override
String get extensionVerificationCopyLink => 'Copy link';
@override
String get extensionVerificationLinkCopied => 'Verification link copied';
@override
String get extensionVerificationOpenBrowser => 'Open browser';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+80
View File
@@ -6006,6 +6006,34 @@
"@trackConvertLosslessSuffix": {
"description": "Suffix used in converted lossless quality labels"
},
"trackConvertDithering": "Dithering",
"@trackConvertDithering": {
"description": "Section label for lossless conversion dithering options"
},
"trackConvertResampler": "Resampler",
"@trackConvertResampler": {
"description": "Section label for lossless conversion resampler options"
},
"trackConvertDitherNone": "None",
"@trackConvertDitherNone": {
"description": "Lossless conversion dither option with no dithering applied"
},
"trackConvertDitherTriangular": "TPDF",
"@trackConvertDitherTriangular": {
"description": "Lossless conversion triangular probability density function dither option"
},
"trackConvertDitherTriangularHp": "Triangular HP",
"@trackConvertDitherTriangularHp": {
"description": "Lossless conversion high-pass triangular dither option"
},
"trackConvertResamplerSwr": "SWR",
"@trackConvertResamplerSwr": {
"description": "Lossless conversion default FFmpeg swresample resampler option"
},
"trackConvertResamplerSoxr": "SoXr",
"@trackConvertResamplerSoxr": {
"description": "Lossless conversion SoX resampler option"
},
"updateSeeReleaseNotes": "See release notes for details.",
"@updateSeeReleaseNotes": {
"description": "Fallback changelog text when release notes cannot be parsed"
@@ -6124,5 +6152,57 @@
"regionCountryXK": "Kosovo",
"@regionCountryXK": {
"description": "Country name for SongLink region picker"
},
"extensionVerificationBrowserTitle": "Verification browser",
"@extensionVerificationBrowserTitle": {
"description": "Settings option title for extension verification browser preference"
},
"extensionVerificationBrowserSubtitleExternal": "Open challenges in the default browser first",
"@extensionVerificationBrowserSubtitleExternal": {
"description": "Subtitle when external browser is preferred for extension verification"
},
"extensionVerificationBrowserSubtitleInApp": "Open challenges in the in-app browser first",
"@extensionVerificationBrowserSubtitleInApp": {
"description": "Subtitle when in-app browser is preferred for extension verification"
},
"extensionVerificationBrowserExternal": "External",
"@extensionVerificationBrowserExternal": {
"description": "Chip label for external browser verification mode"
},
"extensionVerificationBrowserInApp": "In-app",
"@extensionVerificationBrowserInApp": {
"description": "Chip label for in-app browser verification mode"
},
"extensionVerificationHelpTitleManual": "Open verification manually",
"@extensionVerificationHelpTitleManual": {
"description": "Dialog title when automatic browser launch for verification fails"
},
"extensionVerificationHelpTitleWaiting": "Verification still waiting",
"@extensionVerificationHelpTitleWaiting": {
"description": "Dialog title when verification is taking longer than expected"
},
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.",
"@extensionVerificationHelpMessageManual": {
"description": "Dialog message when automatic browser launch for verification fails"
},
"extensionVerificationHelpMessageWaiting": "If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.",
"@extensionVerificationHelpMessageWaiting": {
"description": "Dialog message when verification may need manual browser help"
},
"extensionVerificationClose": "Close",
"@extensionVerificationClose": {
"description": "Button to dismiss the extension verification help dialog"
},
"extensionVerificationCopyLink": "Copy link",
"@extensionVerificationCopyLink": {
"description": "Button to copy the extension verification URL"
},
"extensionVerificationLinkCopied": "Verification link copied",
"@extensionVerificationLinkCopied": {
"description": "Snackbar after copying the extension verification URL"
},
"extensionVerificationOpenBrowser": "Open browser",
"@extensionVerificationOpenBrowser": {
"description": "Button to open the extension verification URL in a browser"
}
}
+80
View File
@@ -5850,6 +5850,34 @@
"@trackConvertLosslessSuffix": {
"description": "Suffix used in converted lossless quality labels"
},
"trackConvertDithering": "Dithering",
"@trackConvertDithering": {
"description": "Section label for lossless conversion dithering options"
},
"trackConvertResampler": "Resampler",
"@trackConvertResampler": {
"description": "Section label for lossless conversion resampler options"
},
"trackConvertDitherNone": "Tidak ada",
"@trackConvertDitherNone": {
"description": "Lossless conversion dither option with no dithering applied"
},
"trackConvertDitherTriangular": "TPDF",
"@trackConvertDitherTriangular": {
"description": "Lossless conversion triangular probability density function dither option"
},
"trackConvertDitherTriangularHp": "Triangular HP",
"@trackConvertDitherTriangularHp": {
"description": "Lossless conversion high-pass triangular dither option"
},
"trackConvertResamplerSwr": "SWR",
"@trackConvertResamplerSwr": {
"description": "Lossless conversion default FFmpeg swresample resampler option"
},
"trackConvertResamplerSoxr": "SoXr",
"@trackConvertResamplerSoxr": {
"description": "Lossless conversion SoX resampler option"
},
"updateSeeReleaseNotes": "Lihat catatan rilis untuk detail.",
"@updateSeeReleaseNotes": {
"description": "Fallback changelog text when release notes cannot be parsed"
@@ -5968,5 +5996,57 @@
"regionCountryXK": "Kosovo",
"@regionCountryXK": {
"description": "Country name for SongLink region picker"
},
"extensionVerificationBrowserTitle": "Browser verifikasi",
"@extensionVerificationBrowserTitle": {
"description": "Settings option title for extension verification browser preference"
},
"extensionVerificationBrowserSubtitleExternal": "Buka tantangan di browser default terlebih dahulu",
"@extensionVerificationBrowserSubtitleExternal": {
"description": "Subtitle when external browser is preferred for extension verification"
},
"extensionVerificationBrowserSubtitleInApp": "Buka tantangan di browser dalam aplikasi terlebih dahulu",
"@extensionVerificationBrowserSubtitleInApp": {
"description": "Subtitle when in-app browser is preferred for extension verification"
},
"extensionVerificationBrowserExternal": "Eksternal",
"@extensionVerificationBrowserExternal": {
"description": "Chip label for external browser verification mode"
},
"extensionVerificationBrowserInApp": "Dalam aplikasi",
"@extensionVerificationBrowserInApp": {
"description": "Chip label for in-app browser verification mode"
},
"extensionVerificationHelpTitleManual": "Buka verifikasi secara manual",
"@extensionVerificationHelpTitleManual": {
"description": "Dialog title when automatic browser launch for verification fails"
},
"extensionVerificationHelpTitleWaiting": "Verifikasi masih menunggu",
"@extensionVerificationHelpTitleWaiting": {
"description": "Dialog title when verification is taking longer than expected"
},
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile tidak dapat membuka browser secara otomatis. Buka tautan ini di browser Anda, atau salin secara manual.",
"@extensionVerificationHelpMessageManual": {
"description": "Dialog message when automatic browser launch for verification fails"
},
"extensionVerificationHelpMessageWaiting": "Jika browser tidak terbuka, atau verifikasi selesai tetapi tidak kembali ke SpotiFLAC Mobile, buka tautan ini lagi atau salin secara manual.",
"@extensionVerificationHelpMessageWaiting": {
"description": "Dialog message when verification may need manual browser help"
},
"extensionVerificationClose": "Tutup",
"@extensionVerificationClose": {
"description": "Button to dismiss the extension verification help dialog"
},
"extensionVerificationCopyLink": "Salin tautan",
"@extensionVerificationCopyLink": {
"description": "Button to copy the extension verification URL"
},
"extensionVerificationLinkCopied": "Tautan verifikasi disalin",
"@extensionVerificationLinkCopied": {
"description": "Snackbar after copying the extension verification URL"
},
"extensionVerificationOpenBrowser": "Buka browser",
"@extensionVerificationOpenBrowser": {
"description": "Button to open the extension verification URL in a browser"
}
}
+12 -10
View File
@@ -43,14 +43,15 @@ class AppSettings {
final String singleFilenameFormat;
final String albumFolderStructure;
final bool showExtensionStore;
final String
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads;
final bool autoExportFailedDownloads;
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final bool
@@ -66,16 +67,13 @@ class AppSettings {
final String localLibraryPath;
final String
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
final bool
localLibraryShowDuplicates;
final bool localLibraryShowDuplicates;
final String
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
final bool
hasCompletedTutorial;
final bool hasCompletedTutorial;
final List<String>
lyricsProviders;
final List<String> lyricsProviders;
final bool
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
final bool
@@ -90,8 +88,7 @@ class AppSettings {
final String
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
final bool
deduplicateDownloads;
final bool deduplicateDownloads;
final bool saveDownloadHistory;
final String playerMode;
@@ -132,6 +129,7 @@ class AppSettings {
this.singleFilenameFormat = '{title} - {artist}',
this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true,
this.extensionVerificationBrowserMode = 'in_app_first',
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
@@ -199,6 +197,7 @@ class AppSettings {
String? singleFilenameFormat,
String? albumFolderStructure,
bool? showExtensionStore,
String? extensionVerificationBrowserMode,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
@@ -274,6 +273,9 @@ class AppSettings {
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
extensionVerificationBrowserMode:
extensionVerificationBrowserMode ??
this.extensionVerificationBrowserMode,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
+3
View File
@@ -48,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
extensionVerificationBrowserMode:
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
@@ -125,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'singleFilenameFormat': instance.singleFilenameFormat,
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
+73 -12
View File
@@ -2096,13 +2096,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
),
);
final opened = await openPendingExtensionVerification(
normalizedExtensionId,
);
if (!opened) return false;
final browserMode = ref
.read(settingsProvider)
.extensionVerificationBrowserMode;
Uri? authUri;
Timer? helpDialogTimer;
final event = await grantEventFuture;
return event.success;
try {
final opened = await openPendingExtensionVerification(
normalizedExtensionId,
browserMode: browserMode,
onAuthUri: (uri) => authUri = uri,
);
if (!opened) return false;
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
);
final event = await grantEventFuture;
return event.success;
} finally {
helpDialogTimer?.cancel();
}
}
Future<bool> _handleVerificationRequiredDownload(
@@ -6069,15 +6087,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (status == 'skipped') {
updateItemStatus(itemId, DownloadStatus.skipped);
} else {
final errorType = result is Map
? _downloadErrorTypeFromBackend(
Map<String, dynamic>.from(result)['error_type']?.toString(),
)
: DownloadErrorType.unknown;
final resultMap = result is Map
? Map<String, dynamic>.from(result)
: null;
final errorMsg = (error == null || error.isEmpty)
? (resultMap?['error']?.toString() ?? 'Download failed')
: error;
final backendErrorType = resultMap == null
? DownloadErrorType.unknown
: _downloadErrorTypeFromBackend(
resultMap['error_type']?.toString(),
);
final errorType = backendErrorType == DownloadErrorType.unknown
? _downloadErrorTypeFromMessage(errorMsg)
: backendErrorType;
if (errorType == DownloadErrorType.verificationRequired) {
_log.i(
'Android native worker requires verification for ${current.track.name}; switching back to interactive queue',
);
try {
await PlatformBridge.cancelNativeDownloadWorker();
} catch (e) {
_log.w('Failed to cancel native worker before verification: $e');
}
await _handleVerificationRequiredDownload(
current,
errorMsg,
_nativeWorkerVerificationService(resultMap, context),
);
continue;
}
updateItemStatus(
itemId,
DownloadStatus.failed,
error: error == null || error.isEmpty ? 'Download failed' : error,
error: errorMsg,
errorType: errorType,
);
_failedInSession++;
@@ -6901,6 +6944,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
String _nativeWorkerVerificationService(
Map<String, dynamic>? result,
_NativeWorkerRequestContext context,
) {
if (result != null) {
for (final key in const [
'service',
'verification_service',
'provider',
'source',
]) {
final value = result[key]?.toString().trim() ?? '';
if (value.isNotEmpty) return value;
}
}
return context.item.service;
}
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
final lowerMsg = errorMsg.toLowerCase();
if (isExtensionVerificationRequired(errorMsg)) {
+24
View File
@@ -28,6 +28,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
'album',
'playlist',
};
static const Set<String> _extensionVerificationBrowserModeValues = {
'external_first',
'in_app_first',
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -79,6 +83,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: loaded.defaultService,
searchProvider: loaded.searchProvider,
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(
loaded.extensionVerificationBrowserMode,
),
);
await _runMigrations(prefs);
@@ -270,6 +278,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'all';
}
String _normalizeExtensionVerificationBrowserMode(String value) {
final normalized = value.trim().toLowerCase();
if (_extensionVerificationBrowserModeValues.contains(normalized)) {
return normalized;
}
return 'in_app_first';
}
String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
final normalized = providerId?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return providerId;
@@ -557,6 +573,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setExtensionVerificationBrowserMode(String mode) {
state = state.copyWith(
extensionVerificationBrowserMode:
_normalizeExtensionVerificationBrowserMode(mode),
);
_saveSettings();
}
void setLocale(String locale) {
state = state.copyWith(locale: locale);
_saveSettings();
+15
View File
@@ -683,12 +683,26 @@ class TrackNotifier extends Notifier<TrackState> {
}
});
final browserMode = ref
.read(settingsProvider)
.extensionVerificationBrowserMode;
Uri? authUri;
Timer? helpDialogTimer;
try {
final opened = await openPendingExtensionVerification(
normalizedExtensionId,
browserMode: browserMode,
onAuthUri: (uri) => authUri = uri,
);
if (!opened) return false;
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
);
final event = await grantCompleter.future.timeout(
const Duration(minutes: 5),
);
@@ -699,6 +713,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
return false;
} finally {
helpDialogTimer?.cancel();
await grantSub.cancel();
}
}
+6 -1
View File
@@ -1140,6 +1140,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -1149,13 +1150,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
confirmLabel: sheetConfirmLabel,
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert: (format, bitrate, losslessQuality) {
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
),
@@ -1168,6 +1170,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <DownloadHistoryItem>[];
@@ -1322,6 +1326,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
deleteOriginal: !isSaf,
sourceBitDepth: item.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
+6 -1
View File
@@ -1319,6 +1319,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -1328,13 +1329,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
confirmLabel: sheetConfirmLabel,
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert: (format, bitrate, losslessQuality) {
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
Navigator.pop(sheetContext);
_performBatchConversion(
allTracks: allTracks,
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
),
@@ -1347,6 +1349,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -1500,6 +1504,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
deleteOriginal: !isSaf,
sourceBitDepth: item.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
+6 -1
View File
@@ -5560,6 +5560,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -5569,7 +5570,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
confirmLabel: sheetConfirmLabel,
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
onConvert: (format, bitrate, losslessQuality) {
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
didStartConversion = true;
Navigator.pop(sheetContext);
_performBatchConversion(
@@ -5577,6 +5578,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
targetFormat: format,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
),
@@ -5611,6 +5613,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final itemsById = {for (final item in allItems) item.id: item};
final selectedItems = <UnifiedLibraryItem>[];
@@ -5770,6 +5774,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
sourceBitDepth:
item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
@@ -75,6 +75,12 @@ class AppSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setShowExtensionStore(v),
),
_VerificationBrowserModeSelector(
currentMode: settings.extensionVerificationBrowserMode,
onChanged: (mode) => ref
.read(settingsProvider.notifier)
.setExtensionVerificationBrowserMode(mode),
),
SettingsSwitchItem(
icon: Icons.system_update,
title: context.l10n.optionsCheckUpdates,
@@ -374,6 +380,96 @@ class _UpdateChannelSelector extends StatelessWidget {
}
}
class _VerificationBrowserModeSelector extends StatelessWidget {
final String currentMode;
final ValueChanged<String> onChanged;
const _VerificationBrowserModeSelector({
required this.currentMode,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final normalizedMode = currentMode == 'in_app_first'
? 'in_app_first'
: currentMode == 'external_first'
? 'external_first'
: 'in_app_first';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.open_in_browser,
color: colorScheme.onSurfaceVariant,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.extensionVerificationBrowserTitle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
normalizedMode == 'external_first'
? context
.l10n
.extensionVerificationBrowserSubtitleExternal
: context
.l10n
.extensionVerificationBrowserSubtitleInApp,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
_ChannelChip(
label: context.l10n.extensionVerificationBrowserExternal,
isSelected: normalizedMode == 'external_first',
onTap: () => onChanged('external_first'),
),
const SizedBox(width: 8),
_ChannelChip(
label: context.l10n.extensionVerificationBrowserInApp,
isSelected: normalizedMode == 'in_app_first',
onTap: () => onChanged('in_app_first'),
),
],
),
],
),
),
Divider(
height: 1,
thickness: 1,
indent: 56,
endIndent: 20,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
class _ChannelChip extends StatelessWidget {
final String label;
final bool isSelected;
+333 -242
View File
@@ -3822,12 +3822,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
int? selectedMaxBitDepth;
int? selectedMaxSampleRate;
String selectedDither = 'none';
String selectedResampler = 'swr';
final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth);
final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate);
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
@@ -3911,270 +3914,352 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
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,
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => SafeArea(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
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),
),
),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 18),
Text(
context.l10n.trackConvertTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
currentFormat,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
const SizedBox(height: 18),
Text(
context.l10n.trackConvertTitle,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
currentFormat,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 20),
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertTargetFormat),
Wrap(
spacing: 8,
runSpacing: 8,
children: formats.map((format) {
return choice(
label: format,
selected: format == selectedFormat,
onTap: () {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
isLosslessConversionTarget(format);
if (!isLosslessTarget) {
selectedBitrate = defaultBitrateForFormat(
format,
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertTargetFormat,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: formats.map((format) {
return choice(
label: format,
selected: format == selectedFormat,
onTap: () {
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
isLosslessConversionTarget(format);
if (!isLosslessTarget) {
selectedBitrate =
defaultBitrateForFormat(format);
} else {
selectedMaxBitDepth = null;
selectedMaxSampleRate = null;
selectedDither = 'none';
selectedResampler = 'swr';
}
});
},
);
}).toList(),
),
],
),
),
if (!isLosslessTarget)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: bitrates.map((br) {
return choice(
label: br,
selected: br == selectedBitrate,
onTap: () => setSheetState(
() => selectedBitrate = br,
),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && bitDepthOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.audioAnalysisBitDepth,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
choice(
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxBitDepth == null,
onTap: () => setSheetState(() {
selectedMaxBitDepth = null;
selectedDither = 'none';
}),
),
...bitDepthOptions.map((depth) {
return choice(
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == selectedMaxBitDepth,
onTap: () => setSheetState(
() => selectedMaxBitDepth = depth,
),
);
} else {
selectedMaxBitDepth = null;
selectedMaxSampleRate = null;
}
});
},
);
}).toList(),
),
],
),
),
if (!isLosslessTarget)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: bitrates.map((br) {
return choice(
label: br,
selected: br == selectedBitrate,
onTap: () =>
setSheetState(() => selectedBitrate = br),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && bitDepthOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.audioAnalysisBitDepth),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
choice(
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxBitDepth == null,
onTap: () => setSheetState(
() => selectedMaxBitDepth = null,
),
}),
],
),
...bitDepthOptions.map((depth) {
return choice(
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == selectedMaxBitDepth,
onTap: () => setSheetState(
() => selectedMaxBitDepth = depth,
),
);
}),
],
),
],
),
),
),
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(context.l10n.audioAnalysisSampleRate),
Wrap(
spacing: 8,
runSpacing: 8,
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
choice(
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxSampleRate == null,
onTap: () => setSheetState(
() => selectedMaxSampleRate = null,
),
sectionLabel(
context.l10n.audioAnalysisSampleRate,
),
...sampleRateOptions.map((rate) {
return choice(
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
Wrap(
spacing: 8,
runSpacing: 8,
children: [
choice(
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: selectedMaxSampleRate == null,
onTap: () => setSheetState(() {
selectedMaxSampleRate = null;
selectedResampler = 'swr';
}),
),
selected: rate == selectedMaxSampleRate,
onTap: () => setSheetState(
() => selectedMaxSampleRate = rate,
),
);
}),
...sampleRateOptions.map((rate) {
return choice(
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == selectedMaxSampleRate,
onTap: () => setSheetState(
() => selectedMaxSampleRate = rate,
),
);
}),
],
),
],
),
],
),
),
if (isLosslessTarget && isLosslessSource)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(
Icons.verified,
size: 18,
color: colorScheme.primary,
if (isLosslessTarget && selectedMaxBitDepth != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertDithering,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessDitherOptions.map((mode) {
return choice(
label: context.l10n
.losslessDitherOptionLabel(mode),
selected: mode == selectedDither,
onTap: () => setSheetState(
() => selectedDither = mode,
),
);
}).toList(),
),
],
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedMaxBitDepth == null &&
selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n
.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth:
selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
),
if (isLosslessTarget && selectedMaxSampleRate != null)
card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionLabel(
context.l10n.trackConvertResampler,
),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessResamplerOptions.map((
mode,
) {
return choice(
label: context.l10n
.losslessResamplerOptionLabel(mode),
selected: mode == selectedResampler,
onTap: () => setSheetState(
() => selectedResampler = mode,
),
);
}).toList(),
),
],
),
),
if (isLosslessTarget && isLosslessSource)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(
alpha: 0.4,
),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(
Icons.verified,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
selectedMaxBitDepth == null &&
selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n
.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth:
selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
),
originalLabel:
labels.original,
originalQualityLabel:
labels.originalQuality,
),
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.primary),
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
Navigator.pop(context);
_confirmAndConvert(
context: this.context,
sourceFormat: currentFormat,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
losslessQuality: LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
),
losslessProcessing:
LosslessConversionProcessing(
dither: selectedDither,
resampler: selectedResampler,
),
);
},
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
Navigator.pop(context);
_confirmAndConvert(
context: this.context,
sourceFormat: currentFormat,
targetFormat: selectedFormat,
bitrate: selectedBitrate,
losslessQuality: LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
label: Text(
isLosslessTarget
? context.l10n
.trackConvertActionLabelLossless(
currentFormat,
selectedFormat,
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate:
selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
)
: context.l10n.trackConvertActionLabelLossy(
currentFormat,
selectedFormat,
selectedBitrate,
),
),
);
},
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
label: Text(
isLosslessTarget
? context.l10n.trackConvertActionLabelLossless(
currentFormat,
selectedFormat,
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: selectedMaxBitDepth,
maxSampleRate: selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
)
: context.l10n.trackConvertActionLabelLossy(
currentFormat,
selectedFormat,
selectedBitrate,
),
),
),
],
),
],
),
),
),
);
@@ -4642,6 +4727,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) {
final isLossless = isLosslessConversionTarget(targetFormat);
showDialog<void>(
@@ -4687,6 +4774,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
targetFormat: targetFormat,
bitrate: bitrate,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
},
child: Text(dialogContext.l10n.trackConvertFormat),
@@ -4702,6 +4790,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
required String bitrate,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
if (_isConverting) return;
setState(() => _isConverting = true);
@@ -4778,6 +4868,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
deleteOriginal: !isSaf,
sourceBitDepth: bitDepth,
losslessQuality: losslessQuality,
losslessProcessing: losslessProcessing,
);
if (coverPath != null) {
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
class AppNavigationService {
static final GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>();
const AppNavigationService._();
}
+86 -18
View File
@@ -393,12 +393,19 @@ class FFmpegService {
required String codec,
int? targetBitDepth,
int? targetSampleRate,
LosslessConversionProcessing processing =
const LosslessConversionProcessing(),
}) {
if (targetSampleRate != null && targetSampleRate > 0) {
arguments
..add('-ar')
..add(targetSampleRate.toString());
}
final sampleFmt = _losslessOutputSampleFormat(
codec: codec,
targetBitDepth: targetBitDepth,
);
_appendLosslessAresampleFilter(
arguments,
targetSampleRate: targetSampleRate,
outputSampleFormat: sampleFmt,
processing: processing,
);
if (targetBitDepth == null || targetBitDepth <= 0) return;
if (codec == 'flac') {
@@ -431,6 +438,48 @@ class FFmpegService {
}
}
static String? _losslessOutputSampleFormat({
required String codec,
int? targetBitDepth,
}) {
if (targetBitDepth == null || targetBitDepth <= 0) return null;
if (codec == 'flac') {
return targetBitDepth <= 16 ? 's16' : 's32';
}
if (codec == 'alac') {
return targetBitDepth <= 16 ? 's16p' : 's32p';
}
if (codec == 'pcm') {
return targetBitDepth <= 16 ? 's16' : 's32';
}
return null;
}
static void _appendLosslessAresampleFilter(
List<String> arguments, {
int? targetSampleRate,
String? outputSampleFormat,
LosslessConversionProcessing processing =
const LosslessConversionProcessing(),
}) {
final hasSampleRate = targetSampleRate != null && targetSampleRate > 0;
final hasSampleFormat =
outputSampleFormat != null && outputSampleFormat.trim().isNotEmpty;
if (!hasSampleRate && !hasSampleFormat && !processing.hasDither) return;
final options = <String>[
'resampler=${processing.normalizedResampler}',
if (hasSampleRate) 'osr=$targetSampleRate',
if (hasSampleFormat) 'osf=${outputSampleFormat.trim()}',
if (processing.hasDither) 'dither_method=${processing.normalizedDither}',
];
arguments
..add('-af')
..add('aresample=${options.join(':')}');
}
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
@@ -2349,6 +2398,8 @@ class FFmpegService {
int? sourceBitDepth,
LosslessConversionQuality losslessQuality =
const LosslessConversionQuality(),
LosslessConversionProcessing losslessProcessing =
const LosslessConversionProcessing(),
}) async {
final format = targetFormat.toLowerCase();
if (!const {
@@ -2380,6 +2431,7 @@ class FFmpegService {
coverPath: coverPath,
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
processing: losslessProcessing,
deleteOriginal: deleteOriginal,
);
}
@@ -2391,6 +2443,7 @@ class FFmpegService {
artistTagMode: artistTagMode,
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
processing: losslessProcessing,
deleteOriginal: deleteOriginal,
);
}
@@ -2403,6 +2456,7 @@ class FFmpegService {
sourceBitDepth: sourceBitDepth,
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
processing: losslessProcessing,
deleteOriginal: deleteOriginal,
);
}
@@ -2500,6 +2554,8 @@ class FFmpegService {
String? coverPath,
int? targetBitDepth,
int? targetSampleRate,
LosslessConversionProcessing processing =
const LosslessConversionProcessing(),
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.m4a');
@@ -2539,6 +2595,7 @@ class FFmpegService {
codec: 'alac',
targetBitDepth: targetBitDepth,
targetSampleRate: targetSampleRate,
processing: processing,
);
arguments
..add('-map_metadata')
@@ -2553,7 +2610,9 @@ class FFmpegService {
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC'
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}',
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}'
'${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}'
'${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}',
);
final result = await _executeWithArguments(arguments);
@@ -2583,6 +2642,8 @@ class FFmpegService {
String artistTagMode = artistTagModeJoined,
int? targetBitDepth,
int? targetSampleRate,
LosslessConversionProcessing processing =
const LosslessConversionProcessing(),
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
@@ -2624,6 +2685,7 @@ class FFmpegService {
codec: 'flac',
targetBitDepth: targetBitDepth,
targetSampleRate: targetSampleRate,
processing: processing,
);
arguments
..add('-map_metadata')
@@ -2642,7 +2704,9 @@ class FFmpegService {
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC'
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}',
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}'
'${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}'
'${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}',
);
final result = await _executeWithArguments(arguments);
@@ -2676,6 +2740,8 @@ class FFmpegService {
int? sourceBitDepth,
int? targetBitDepth,
int? targetSampleRate,
LosslessConversionProcessing processing =
const LosslessConversionProcessing(),
bool deleteOriginal = true,
}) async {
final isAiff = container == 'aiff';
@@ -2697,22 +2763,24 @@ class FFmpegService {
inputPath,
'-map',
'0:a',
'-c:a',
codec,
if (targetSampleRate != null && targetSampleRate > 0) ...[
'-ar',
targetSampleRate.toString(),
],
'-map_metadata',
'-1',
outputPath,
'-y',
];
_appendLosslessAresampleFilter(
arguments,
targetSampleRate: targetSampleRate,
outputSampleFormat: _losslessOutputSampleFormat(
codec: 'pcm',
targetBitDepth: targetBitDepth,
),
processing: processing,
);
arguments.addAll(['-c:a', codec, '-map_metadata', '-1', outputPath, '-y']);
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit'
'${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''})',
'${targetSampleRate != null ? ', ${targetSampleRate}Hz' : ''}'
'${processing.hasDither ? ', dither=${processing.normalizedDither}' : ''}'
'${processing.normalizedResampler != 'swr' ? ', resampler=${processing.normalizedResampler}' : ''})',
);
final result = await _executeWithArguments(arguments);
if (!result.success) {
+53 -13
View File
@@ -19,6 +19,36 @@ const List<int> losslessConversionSampleRateOptions = [
const List<int> losslessConversionBitDepthOptions = [16, 24];
const List<String> losslessDitherOptions = [
'none',
'triangular',
'triangular_hp',
];
const List<String> losslessResamplerOptions = ['swr', 'soxr'];
class LosslessConversionProcessing {
final String dither;
final String resampler;
const LosslessConversionProcessing({
this.dither = 'none',
this.resampler = 'swr',
});
String get normalizedDither {
final normalized = dither.trim().toLowerCase();
return losslessDitherOptions.contains(normalized) ? normalized : 'none';
}
String get normalizedResampler {
final normalized = resampler.trim().toLowerCase();
return losslessResamplerOptions.contains(normalized) ? normalized : 'swr';
}
bool get hasDither => normalizedDither != 'none';
}
List<int> availableLosslessBitDepthOptions(int? sourceBitDepth) {
if (sourceBitDepth == null || sourceBitDepth <= 0) {
return losslessConversionBitDepthOptions;
@@ -189,12 +219,29 @@ extension LosslessConversionLabelsL10n on AppLocalizations {
originalQuality: trackConvertOriginalQuality,
lossless: trackConvertLosslessSuffix,
);
String losslessDitherOptionLabel(String dither) {
switch (dither.trim().toLowerCase()) {
case 'triangular':
return trackConvertDitherTriangular;
case 'triangular_hp':
return trackConvertDitherTriangularHp;
default:
return trackConvertDitherNone;
}
}
String losslessResamplerOptionLabel(String resampler) {
switch (resampler.trim().toLowerCase()) {
case 'soxr':
return trackConvertResamplerSoxr;
default:
return trackConvertResamplerSwr;
}
}
}
String losslessBitDepthLabel(
int? bitDepth, {
required String originalLabel,
}) {
String losslessBitDepthLabel(int? bitDepth, {required String originalLabel}) {
return bitDepth == null ? originalLabel : '$bitDepth-bit';
}
@@ -216,10 +263,7 @@ String losslessQualityLabel(
final parts = <String>[];
if (quality.maxBitDepth != null) {
parts.add(
losslessBitDepthLabel(
quality.maxBitDepth,
originalLabel: originalLabel,
),
losslessBitDepthLabel(quality.maxBitDepth, originalLabel: originalLabel),
);
}
if (quality.maxSampleRate != null) {
@@ -250,11 +294,7 @@ String convertedAudioQualityLabel({
return '$upper ${losslessBitDepthLabel(actualBitDepth, originalLabel: labels.original)}/${losslessSampleRateLabel(actualSampleRate, originalLabel: labels.original)}';
}
if (losslessQuality.hasCaps) {
return '$upper ${losslessQualityLabel(
losslessQuality,
originalLabel: labels.original,
originalQualityLabel: labels.originalQuality,
)}';
return '$upper ${losslessQualityLabel(losslessQuality, originalLabel: labels.original, originalQualityLabel: labels.originalQuality)}';
}
return '$upper ${labels.lossless}';
}
+136 -5
View File
@@ -1,3 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/app_navigation_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -27,7 +33,11 @@ bool _containsHttpStatusCode(String message, String code) {
message.contains('$code;');
}
Future<bool> openPendingExtensionVerification(String extensionId) async {
Future<bool> openPendingExtensionVerification(
String extensionId, {
String browserMode = 'in_app_first',
void Function(Uri authUri)? onAuthUri,
}) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
@@ -40,11 +50,9 @@ Future<bool> openPendingExtensionVerification(String extensionId) async {
final uri = Uri.tryParse(authUrl);
if (uri == null) return false;
onAuthUri?.call(uri);
var launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
if (!launched) {
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
}
final launched = await _launchVerificationUrl(uri, browserMode);
if (launched) {
_log.i('Opened verification challenge for $normalizedExtensionId');
@@ -52,6 +60,12 @@ Future<bool> openPendingExtensionVerification(String extensionId) async {
_log.w(
'Could not open verification challenge for $normalizedExtensionId',
);
return showExtensionVerificationHelpDialog(
normalizedExtensionId,
uri,
browserMode: browserMode,
immediateFailure: true,
);
}
return launched;
} catch (e) {
@@ -61,3 +75,120 @@ Future<bool> openPendingExtensionVerification(String extensionId) async {
return false;
}
}
Timer? scheduleExtensionVerificationHelpDialog(
String extensionId,
Uri? authUri, {
String browserMode = 'in_app_first',
Duration delay = const Duration(seconds: 20),
}) {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty || authUri == null) return null;
return Timer(delay, () {
unawaited(
showExtensionVerificationHelpDialog(
normalizedExtensionId,
authUri,
browserMode: browserMode,
),
);
});
}
Future<bool> showExtensionVerificationHelpDialog(
String extensionId,
Uri authUri, {
String browserMode = 'in_app_first',
bool immediateFailure = false,
}) async {
final context = AppNavigationService.rootNavigatorKey.currentContext;
if (context == null) {
_log.w('Cannot show verification help dialog without root context');
return false;
}
final l10n = context.l10n;
final title = immediateFailure
? l10n.extensionVerificationHelpTitleManual
: l10n.extensionVerificationHelpTitleWaiting;
final message = immediateFailure
? l10n.extensionVerificationHelpMessageManual
: l10n.extensionVerificationHelpMessageWaiting;
await showDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: false,
builder: (dialogContext) {
final dialogL10n = dialogContext.l10n;
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(message),
const SizedBox(height: 16),
DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(dialogContext).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: SelectableText(
authUri.toString(),
maxLines: 4,
minLines: 1,
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(dialogL10n.extensionVerificationClose),
),
TextButton.icon(
icon: const Icon(Icons.copy),
label: Text(dialogL10n.extensionVerificationCopyLink),
onPressed: () {
Clipboard.setData(ClipboardData(text: authUri.toString()));
ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar(
SnackBar(
content: Text(dialogL10n.extensionVerificationLinkCopied),
),
);
},
),
FilledButton.icon(
icon: const Icon(Icons.open_in_browser),
label: Text(dialogL10n.extensionVerificationOpenBrowser),
onPressed: () {
unawaited(_launchVerificationUrl(authUri, browserMode));
},
),
],
);
},
);
return true;
}
Future<bool> _launchVerificationUrl(Uri uri, String browserMode) async {
final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first';
final firstMode = preferInApp
? LaunchMode.inAppBrowserView
: LaunchMode.externalApplication;
final fallbackMode = preferInApp
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView;
var launched = await launchUrl(uri, mode: firstMode);
if (!launched) {
launched = await launchUrl(uri, mode: fallbackMode);
}
return launched;
}
+299 -216
View File
@@ -16,6 +16,7 @@ class BatchConvertSheet extends StatefulWidget {
String format,
String bitrate,
LosslessConversionQuality losslessQuality,
LosslessConversionProcessing losslessProcessing,
)
onConvert;
@@ -42,6 +43,8 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
late String _selectedBitrate;
int? _selectedMaxBitDepth;
int? _selectedMaxSampleRate;
String _selectedDither = 'none';
String _selectedResampler = 'swr';
String _defaultBitrateForFormat(String format) {
if (format == 'Opus') return '128k';
@@ -70,242 +73,322 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
widget.sourceSampleRate,
);
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => SafeArea(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
),
),
const SizedBox(height: 18),
Text(
widget.title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
),
],
const SizedBox(height: 20),
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertTargetFormat),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.formats.map((format) {
return _choice(
cs,
label: format,
selected: format == _selectedFormat,
onTap: () {
setState(() {
_selectedFormat = format;
_isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!_isLosslessTarget) {
_selectedBitrate = _defaultBitrateForFormat(
format,
);
} else {
_selectedMaxBitDepth = null;
_selectedMaxSampleRate = null;
}
});
},
);
}).toList(),
const SizedBox(height: 18),
Text(
widget.title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 4),
Text(
widget.subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: 20),
if (!_isLosslessTarget)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: _bitrates.map((br) {
return _choice(
cs,
label: br,
selected: br == _selectedBitrate,
onTap: () => setState(() => _selectedBitrate = br),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget && bitDepthOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisBitDepth),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_choice(
cs,
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
selected: _selectedMaxBitDepth == null,
onTap: () =>
setState(() => _selectedMaxBitDepth = null),
),
...bitDepthOptions.map((depth) {
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertTargetFormat),
Wrap(
spacing: 8,
runSpacing: 8,
children: widget.formats.map((format) {
return _choice(
cs,
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == _selectedMaxBitDepth,
onTap: () =>
setState(() => _selectedMaxBitDepth = depth),
label: format,
selected: format == _selectedFormat,
onTap: () {
setState(() {
_selectedFormat = format;
_isLosslessTarget = isLosslessConversionTarget(
format,
);
if (!_isLosslessTarget) {
_selectedBitrate = _defaultBitrateForFormat(
format,
);
} else {
_selectedMaxBitDepth = null;
_selectedMaxSampleRate = null;
_selectedDither = 'none';
_selectedResampler = 'swr';
}
});
},
);
}),
],
),
],
}).toList(),
),
],
),
),
),
if (_isLosslessTarget && sampleRateOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisSampleRate),
Wrap(
spacing: 8,
runSpacing: 8,
if (!_isLosslessTarget)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_choice(
cs,
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: _selectedMaxSampleRate == null,
onTap: () =>
setState(() => _selectedMaxSampleRate = null),
_sectionLabel(cs, context.l10n.trackConvertBitrate),
Wrap(
spacing: 8,
runSpacing: 8,
children: _bitrates.map((br) {
return _choice(
cs,
label: br,
selected: br == _selectedBitrate,
onTap: () =>
setState(() => _selectedBitrate = br),
);
}).toList(),
),
...sampleRateOptions.map((rate) {
return _choice(
cs,
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == _selectedMaxSampleRate,
onTap: () =>
setState(() => _selectedMaxSampleRate = rate),
);
}),
],
),
],
),
),
),
if (_isLosslessTarget)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: cs.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Icons.verified, size: 18, color: cs.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedMaxBitDepth == null &&
_selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel: labels.originalQuality,
),
if (_isLosslessTarget && bitDepthOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisBitDepth),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_choice(
cs,
label: losslessBitDepthLabel(
null,
originalLabel: labels.original,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.primary),
selected: _selectedMaxBitDepth == null,
onTap: () => setState(() {
_selectedMaxBitDepth = null;
_selectedDither = 'none';
}),
),
...bitDepthOptions.map((depth) {
return _choice(
cs,
label: losslessBitDepthLabel(
depth,
originalLabel: labels.original,
),
selected: depth == _selectedMaxBitDepth,
onTap: () => setState(
() => _selectedMaxBitDepth = depth,
),
);
}),
],
),
],
),
),
if (_isLosslessTarget && sampleRateOptions.isNotEmpty)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.audioAnalysisSampleRate),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_choice(
cs,
label: losslessSampleRateLabel(
null,
originalLabel: labels.original,
),
selected: _selectedMaxSampleRate == null,
onTap: () => setState(() {
_selectedMaxSampleRate = null;
_selectedResampler = 'swr';
}),
),
...sampleRateOptions.map((rate) {
return _choice(
cs,
label: losslessSampleRateLabel(
rate,
originalLabel: labels.original,
),
selected: rate == _selectedMaxSampleRate,
onTap: () => setState(
() => _selectedMaxSampleRate = rate,
),
);
}),
],
),
],
),
),
if (_isLosslessTarget && _selectedMaxBitDepth != null)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertDithering),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessDitherOptions.map((mode) {
return _choice(
cs,
label: context.l10n.losslessDitherOptionLabel(
mode,
),
selected: mode == _selectedDither,
onTap: () =>
setState(() => _selectedDither = mode),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget && _selectedMaxSampleRate != null)
_card(
cs,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionLabel(cs, context.l10n.trackConvertResampler),
Wrap(
spacing: 8,
runSpacing: 8,
children: losslessResamplerOptions.map((mode) {
return _choice(
cs,
label: context.l10n.losslessResamplerOptionLabel(
mode,
),
selected: mode == _selectedResampler,
onTap: () =>
setState(() => _selectedResampler = mode),
);
}).toList(),
),
],
),
),
if (_isLosslessTarget)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
decoration: BoxDecoration(
color: cs.primaryContainer.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Icons.verified, size: 18, color: cs.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedMaxBitDepth == null &&
_selectedMaxSampleRate == null
? context.l10n.trackConvertLosslessHint
: context.l10n
.trackConvertLosslessOutputWithCap(
losslessQualityLabel(
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate:
_selectedMaxSampleRate,
),
originalLabel: labels.original,
originalQualityLabel:
labels.originalQuality,
),
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.primary),
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => widget.onConvert(
_selectedFormat,
_selectedBitrate,
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
),
LosslessConversionProcessing(
dither: _selectedDither,
resampler: _selectedResampler,
),
),
],
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => widget.onConvert(
_selectedFormat,
_selectedBitrate,
LosslessConversionQuality(
maxBitDepth: _selectedMaxBitDepth,
maxSampleRate: _selectedMaxSampleRate,
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
label: Text(widget.confirmLabel),
),
),
icon: const Icon(Icons.swap_horiz),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
label: Text(widget.confirmLabel),
),
],
),
],
),
),
),
);
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC using extension providers
publish_to: "none"
version: 4.7.0+136
version: 4.7.1+137
environment:
sdk: ^3.10.0