mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8ec744dd | |||
| 5f11f5b114 | |||
| 61f62363b3 | |||
| 3278e32711 | |||
| 0be6455d46 | |||
| 0bf5a39a92 | |||
| 5424648158 | |||
| dcfd95f276 | |||
| 4d6f7d8b08 | |||
| 2c2cf8cdf8 | |||
| 08c738dc69 | |||
| eb36b0bb7b |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, "", "")
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppNavigationService {
|
||||
static final GlobalKey<NavigatorState> rootNavigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
const AppNavigationService._();
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user