mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +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",
|
"name": "SpotiFLAC Mobile",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "4.6.0",
|
"version": "4.7.0",
|
||||||
"versionDate": "2026-06-13",
|
"versionDate": "2026-06-30",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-ios-unsigned.ipa",
|
"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.",
|
"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",
|
"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
|
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||||
// entry header (from the box body start) before child boxes begin.
|
// entry header (from the box body start) before child boxes begin. ok is false
|
||||||
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) int64 {
|
// 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.
|
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||||
base := entry.body()
|
base := entry.body()
|
||||||
if base+10 > entry.end() {
|
if base+10 > entry.end() {
|
||||||
return 8 + 20
|
return 0, false
|
||||||
}
|
}
|
||||||
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||||
|
hdrLen = 8 + 20
|
||||||
switch version {
|
switch version {
|
||||||
case 1:
|
case 1:
|
||||||
return 8 + 20 + 16
|
hdrLen += 16
|
||||||
case 2:
|
case 2:
|
||||||
return 8 + 20 + 36
|
hdrLen += 36
|
||||||
default:
|
|
||||||
return 8 + 20
|
|
||||||
}
|
}
|
||||||
|
if base+hdrLen > entry.end() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hdrLen, true
|
||||||
}
|
}
|
||||||
|
|
||||||
type ac4Location struct {
|
type ac4Location struct {
|
||||||
@@ -232,13 +236,16 @@ func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
|||||||
return data // already v0 (or v2, left untouched)
|
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
|
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||||
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||||
extStart := entry.body() + 8 + 20
|
extStart := entry.body() + 8 + 20
|
||||||
extEnd := extStart + 16
|
extEnd := extStart + 16
|
||||||
|
if extEnd > entry.end() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
delta := int64(-16)
|
delta := int64(-16)
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||||
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
|
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
|
||||||
for _, b := range loc.chain {
|
for _, b := range loc.chain {
|
||||||
growBoxSize(data, b, delta)
|
growBoxSize(data, b, delta)
|
||||||
@@ -273,7 +280,10 @@ func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
|||||||
return nil
|
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
|
childStart := loc.entry.body() + hdrLen
|
||||||
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||||
// Already has dac4; still persist any normalization changes.
|
// 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) {
|
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||||
req := GetPendingAuthRequest(extensionID)
|
req := ensureExtensionPendingAuthRequest(extensionID)
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -3306,6 +3306,40 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
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) {
|
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||||
SetExtensionAuthCode(extensionID, authCode)
|
SetExtensionAuthCode(extensionID, authCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2602,15 +2602,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
resp.Composer = req.Composer
|
resp.Composer = req.Composer
|
||||||
}
|
}
|
||||||
|
|
||||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||||
@@ -2808,15 +2800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
applyExtensionRequestFallbacks(&resp, req)
|
applyExtensionRequestFallbacks(&resp, req)
|
||||||
|
|
||||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||||
@@ -3008,6 +2992,78 @@ func canEmbedGenreLabel(filePath string) bool {
|
|||||||
return err == nil && !info.IsDir() && info.Size() > 0
|
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) {
|
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||||
return p.customSearch(query, options, "", "")
|
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 {
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
}
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
return r.vm.ToValue(map[string]interface{}{"success": true})
|
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 {
|
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
}
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
return r.vm.ToValue(map[string]interface{}{"success": true})
|
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/setup_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.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/theme/dynamic_color_wrapper.dart';
|
||||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: AppNavigationService.rootNavigatorKey,
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '4.7.0';
|
static const String version = '4.7.1';
|
||||||
static const String buildNumber = '136';
|
static const String buildNumber = '137';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|||||||
@@ -7607,6 +7607,48 @@ abstract class AppLocalizations {
|
|||||||
/// **'Lossless'**
|
/// **'Lossless'**
|
||||||
String get trackConvertLosslessSuffix;
|
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
|
/// Fallback changelog text when release notes cannot be parsed
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -7750,6 +7792,84 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Kosovo'**
|
/// **'Kosovo'**
|
||||||
String get regionCountryXK;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -4625,6 +4625,27 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4704,4 +4725,49 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4753,4 +4774,49 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4704,4 +4725,49 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4698,6 +4719,51 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
|
|||||||
@@ -4739,6 +4739,27 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4818,4 +4839,49 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4704,4 +4725,49 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'Lihat catatan rilis untuk detail.';
|
String get updateSeeReleaseNotes => 'Lihat catatan rilis untuk detail.';
|
||||||
|
|
||||||
@@ -4688,4 +4709,49 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4691,4 +4712,49 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4689,4 +4710,49 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4704,4 +4725,49 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4698,6 +4719,51 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
|
|||||||
@@ -4681,6 +4681,27 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4760,4 +4781,49 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4735,4 +4756,49 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4757,4 +4778,49 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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
|
@override
|
||||||
String get trackConvertLosslessSuffix => 'Lossless';
|
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
|
@override
|
||||||
String get updateSeeReleaseNotes => 'See release notes for details.';
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
@@ -4698,6 +4719,51 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get regionCountryXK => 'Kosovo';
|
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`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
|
|||||||
@@ -6006,6 +6006,34 @@
|
|||||||
"@trackConvertLosslessSuffix": {
|
"@trackConvertLosslessSuffix": {
|
||||||
"description": "Suffix used in converted lossless quality labels"
|
"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": "See release notes for details.",
|
||||||
"@updateSeeReleaseNotes": {
|
"@updateSeeReleaseNotes": {
|
||||||
"description": "Fallback changelog text when release notes cannot be parsed"
|
"description": "Fallback changelog text when release notes cannot be parsed"
|
||||||
@@ -6124,5 +6152,57 @@
|
|||||||
"regionCountryXK": "Kosovo",
|
"regionCountryXK": "Kosovo",
|
||||||
"@regionCountryXK": {
|
"@regionCountryXK": {
|
||||||
"description": "Country name for SongLink region picker"
|
"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": {
|
"@trackConvertLosslessSuffix": {
|
||||||
"description": "Suffix used in converted lossless quality labels"
|
"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": "Lihat catatan rilis untuk detail.",
|
||||||
"@updateSeeReleaseNotes": {
|
"@updateSeeReleaseNotes": {
|
||||||
"description": "Fallback changelog text when release notes cannot be parsed"
|
"description": "Fallback changelog text when release notes cannot be parsed"
|
||||||
@@ -5968,5 +5996,57 @@
|
|||||||
"regionCountryXK": "Kosovo",
|
"regionCountryXK": "Kosovo",
|
||||||
"@regionCountryXK": {
|
"@regionCountryXK": {
|
||||||
"description": "Country name for SongLink region picker"
|
"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 singleFilenameFormat;
|
||||||
final String albumFolderStructure;
|
final String albumFolderStructure;
|
||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
|
final String
|
||||||
|
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
|
||||||
final String locale;
|
final String locale;
|
||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String
|
final String
|
||||||
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||||
final bool
|
final bool
|
||||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||||
final bool
|
final bool autoExportFailedDownloads;
|
||||||
autoExportFailedDownloads;
|
|
||||||
final String
|
final String
|
||||||
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||||
final bool
|
final bool
|
||||||
@@ -66,16 +67,13 @@ class AppSettings {
|
|||||||
final String localLibraryPath;
|
final String localLibraryPath;
|
||||||
final String
|
final String
|
||||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||||
final bool
|
final bool localLibraryShowDuplicates;
|
||||||
localLibraryShowDuplicates;
|
|
||||||
final String
|
final String
|
||||||
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||||
|
|
||||||
final bool
|
final bool hasCompletedTutorial;
|
||||||
hasCompletedTutorial;
|
|
||||||
|
|
||||||
final List<String>
|
final List<String> lyricsProviders;
|
||||||
lyricsProviders;
|
|
||||||
final bool
|
final bool
|
||||||
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
|
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
|
||||||
final bool
|
final bool
|
||||||
@@ -90,8 +88,7 @@ class AppSettings {
|
|||||||
final String
|
final String
|
||||||
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
||||||
|
|
||||||
final bool
|
final bool deduplicateDownloads;
|
||||||
deduplicateDownloads;
|
|
||||||
final bool saveDownloadHistory;
|
final bool saveDownloadHistory;
|
||||||
|
|
||||||
final String playerMode;
|
final String playerMode;
|
||||||
@@ -132,6 +129,7 @@ class AppSettings {
|
|||||||
this.singleFilenameFormat = '{title} - {artist}',
|
this.singleFilenameFormat = '{title} - {artist}',
|
||||||
this.albumFolderStructure = 'artist_album',
|
this.albumFolderStructure = 'artist_album',
|
||||||
this.showExtensionStore = true,
|
this.showExtensionStore = true,
|
||||||
|
this.extensionVerificationBrowserMode = 'in_app_first',
|
||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
this.tidalHighFormat = 'mp3_320',
|
this.tidalHighFormat = 'mp3_320',
|
||||||
@@ -199,6 +197,7 @@ class AppSettings {
|
|||||||
String? singleFilenameFormat,
|
String? singleFilenameFormat,
|
||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
|
String? extensionVerificationBrowserMode,
|
||||||
String? locale,
|
String? locale,
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
String? tidalHighFormat,
|
String? tidalHighFormat,
|
||||||
@@ -274,6 +273,9 @@ class AppSettings {
|
|||||||
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
extensionVerificationBrowserMode ??
|
||||||
|
this.extensionVerificationBrowserMode,
|
||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
albumFolderStructure:
|
albumFolderStructure:
|
||||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
|
||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||||
@@ -125,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'singleFilenameFormat': instance.singleFilenameFormat,
|
'singleFilenameFormat': instance.singleFilenameFormat,
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
|
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
|
||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
'tidalHighFormat': instance.tidalHighFormat,
|
||||||
|
|||||||
@@ -2096,13 +2096,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final opened = await openPendingExtensionVerification(
|
final browserMode = ref
|
||||||
normalizedExtensionId,
|
.read(settingsProvider)
|
||||||
);
|
.extensionVerificationBrowserMode;
|
||||||
if (!opened) return false;
|
Uri? authUri;
|
||||||
|
Timer? helpDialogTimer;
|
||||||
|
|
||||||
final event = await grantEventFuture;
|
try {
|
||||||
return event.success;
|
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(
|
Future<bool> _handleVerificationRequiredDownload(
|
||||||
@@ -6069,15 +6087,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (status == 'skipped') {
|
if (status == 'skipped') {
|
||||||
updateItemStatus(itemId, DownloadStatus.skipped);
|
updateItemStatus(itemId, DownloadStatus.skipped);
|
||||||
} else {
|
} else {
|
||||||
final errorType = result is Map
|
final resultMap = result is Map
|
||||||
? _downloadErrorTypeFromBackend(
|
? Map<String, dynamic>.from(result)
|
||||||
Map<String, dynamic>.from(result)['error_type']?.toString(),
|
: null;
|
||||||
)
|
final errorMsg = (error == null || error.isEmpty)
|
||||||
: DownloadErrorType.unknown;
|
? (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(
|
updateItemStatus(
|
||||||
itemId,
|
itemId,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: error == null || error.isEmpty ? 'Download failed' : error,
|
error: errorMsg,
|
||||||
errorType: errorType,
|
errorType: errorType,
|
||||||
);
|
);
|
||||||
_failedInSession++;
|
_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) {
|
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
||||||
final lowerMsg = errorMsg.toLowerCase();
|
final lowerMsg = errorMsg.toLowerCase();
|
||||||
if (isExtensionVerificationRequired(errorMsg)) {
|
if (isExtensionVerificationRequired(errorMsg)) {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
'album',
|
'album',
|
||||||
'playlist',
|
'playlist',
|
||||||
};
|
};
|
||||||
|
static const Set<String> _extensionVerificationBrowserModeValues = {
|
||||||
|
'external_first',
|
||||||
|
'in_app_first',
|
||||||
|
};
|
||||||
|
|
||||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||||
@@ -79,6 +83,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
defaultSearchTab: sanitizedDefaultSearchTab,
|
defaultSearchTab: sanitizedDefaultSearchTab,
|
||||||
defaultService: loaded.defaultService,
|
defaultService: loaded.defaultService,
|
||||||
searchProvider: loaded.searchProvider,
|
searchProvider: loaded.searchProvider,
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
_normalizeExtensionVerificationBrowserMode(
|
||||||
|
loaded.extensionVerificationBrowserMode,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
@@ -270,6 +278,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
return 'all';
|
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) {
|
String? _sanitizeRetiredBuiltInProviderId(String? providerId) {
|
||||||
final normalized = providerId?.trim().toLowerCase();
|
final normalized = providerId?.trim().toLowerCase();
|
||||||
if (normalized == null || normalized.isEmpty) return providerId;
|
if (normalized == null || normalized.isEmpty) return providerId;
|
||||||
@@ -557,6 +573,14 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setExtensionVerificationBrowserMode(String mode) {
|
||||||
|
state = state.copyWith(
|
||||||
|
extensionVerificationBrowserMode:
|
||||||
|
_normalizeExtensionVerificationBrowserMode(mode),
|
||||||
|
);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setLocale(String locale) {
|
void setLocale(String locale) {
|
||||||
state = state.copyWith(locale: locale);
|
state = state.copyWith(locale: locale);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -683,12 +683,26 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final browserMode = ref
|
||||||
|
.read(settingsProvider)
|
||||||
|
.extensionVerificationBrowserMode;
|
||||||
|
Uri? authUri;
|
||||||
|
Timer? helpDialogTimer;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final opened = await openPendingExtensionVerification(
|
final opened = await openPendingExtensionVerification(
|
||||||
normalizedExtensionId,
|
normalizedExtensionId,
|
||||||
|
browserMode: browserMode,
|
||||||
|
onAuthUri: (uri) => authUri = uri,
|
||||||
);
|
);
|
||||||
if (!opened) return false;
|
if (!opened) return false;
|
||||||
|
|
||||||
|
helpDialogTimer = scheduleExtensionVerificationHelpDialog(
|
||||||
|
normalizedExtensionId,
|
||||||
|
authUri,
|
||||||
|
browserMode: browserMode,
|
||||||
|
);
|
||||||
|
|
||||||
final event = await grantCompleter.future.timeout(
|
final event = await grantCompleter.future.timeout(
|
||||||
const Duration(minutes: 5),
|
const Duration(minutes: 5),
|
||||||
);
|
);
|
||||||
@@ -699,6 +713,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
helpDialogTimer?.cancel();
|
||||||
await grantSub.cancel();
|
await grantSub.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1140,6 +1140,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
@@ -1149,13 +1150,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
confirmLabel: sheetConfirmLabel,
|
confirmLabel: sheetConfirmLabel,
|
||||||
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||||
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||||
onConvert: (format, bitrate, losslessQuality) {
|
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_performBatchConversion(
|
_performBatchConversion(
|
||||||
allTracks: allTracks,
|
allTracks: allTracks,
|
||||||
targetFormat: format,
|
targetFormat: format,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1168,6 +1170,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
required String bitrate,
|
required String bitrate,
|
||||||
LosslessConversionQuality losslessQuality =
|
LosslessConversionQuality losslessQuality =
|
||||||
const LosslessConversionQuality(),
|
const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) async {
|
}) async {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final selected = <DownloadHistoryItem>[];
|
final selected = <DownloadHistoryItem>[];
|
||||||
@@ -1322,6 +1326,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
sourceBitDepth: item.bitDepth,
|
sourceBitDepth: item.bitDepth,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
|
|||||||
@@ -1319,6 +1319,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
@@ -1328,13 +1329,14 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
confirmLabel: sheetConfirmLabel,
|
confirmLabel: sheetConfirmLabel,
|
||||||
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||||
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||||
onConvert: (format, bitrate, losslessQuality) {
|
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_performBatchConversion(
|
_performBatchConversion(
|
||||||
allTracks: allTracks,
|
allTracks: allTracks,
|
||||||
targetFormat: format,
|
targetFormat: format,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1347,6 +1349,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
required String bitrate,
|
required String bitrate,
|
||||||
LosslessConversionQuality losslessQuality =
|
LosslessConversionQuality losslessQuality =
|
||||||
const LosslessConversionQuality(),
|
const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) async {
|
}) async {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final selected = <LocalLibraryItem>[];
|
final selected = <LocalLibraryItem>[];
|
||||||
@@ -1500,6 +1504,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
sourceBitDepth: item.bitDepth,
|
sourceBitDepth: item.bitDepth,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
|
|||||||
@@ -5560,6 +5560,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
@@ -5569,7 +5570,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
confirmLabel: sheetConfirmLabel,
|
confirmLabel: sheetConfirmLabel,
|
||||||
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
sourceBitDepth: lowestKnownPositiveInt(sourceBitDepths),
|
||||||
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
sourceSampleRate: lowestKnownPositiveInt(sourceSampleRates),
|
||||||
onConvert: (format, bitrate, losslessQuality) {
|
onConvert: (format, bitrate, losslessQuality, losslessProcessing) {
|
||||||
didStartConversion = true;
|
didStartConversion = true;
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
_performBatchConversion(
|
_performBatchConversion(
|
||||||
@@ -5577,6 +5578,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
targetFormat: format,
|
targetFormat: format,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -5611,6 +5613,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
required String bitrate,
|
required String bitrate,
|
||||||
LosslessConversionQuality losslessQuality =
|
LosslessConversionQuality losslessQuality =
|
||||||
const LosslessConversionQuality(),
|
const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) async {
|
}) async {
|
||||||
final itemsById = {for (final item in allItems) item.id: item};
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
final selectedItems = <UnifiedLibraryItem>[];
|
final selectedItems = <UnifiedLibraryItem>[];
|
||||||
@@ -5770,6 +5774,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
sourceBitDepth:
|
sourceBitDepth:
|
||||||
item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
if (coverPath != null) {
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ class AppSettingsPage extends ConsumerWidget {
|
|||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setShowExtensionStore(v),
|
.setShowExtensionStore(v),
|
||||||
),
|
),
|
||||||
|
_VerificationBrowserModeSelector(
|
||||||
|
currentMode: settings.extensionVerificationBrowserMode,
|
||||||
|
onChanged: (mode) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setExtensionVerificationBrowserMode(mode),
|
||||||
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.system_update,
|
icon: Icons.system_update,
|
||||||
title: context.l10n.optionsCheckUpdates,
|
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 {
|
class _ChannelChip extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
|
|||||||
@@ -3822,12 +3822,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
bool isLosslessTarget = isLosslessConversionTarget(selectedFormat);
|
||||||
int? selectedMaxBitDepth;
|
int? selectedMaxBitDepth;
|
||||||
int? selectedMaxSampleRate;
|
int? selectedMaxSampleRate;
|
||||||
|
String selectedDither = 'none';
|
||||||
|
String selectedResampler = 'swr';
|
||||||
final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth);
|
final bitDepthOptions = availableLosslessBitDepthOptions(bitDepth);
|
||||||
final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate);
|
final sampleRateOptions = availableLosslessSampleRateOptions(sampleRate);
|
||||||
|
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
@@ -3911,270 +3914,352 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return Padding(
|
||||||
child: SingleChildScrollView(
|
padding: EdgeInsets.only(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
child: Column(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: DraggableScrollableSheet(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
initialChildSize: 0.85,
|
||||||
children: [
|
minChildSize: 0.5,
|
||||||
Center(
|
maxChildSize: 0.95,
|
||||||
child: Container(
|
expand: false,
|
||||||
width: 40,
|
builder: (context, scrollController) => SafeArea(
|
||||||
height: 4,
|
child: SingleChildScrollView(
|
||||||
decoration: BoxDecoration(
|
controller: scrollController,
|
||||||
color: colorScheme.onSurfaceVariant.withValues(
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||||
alpha: 0.4,
|
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(
|
||||||
const SizedBox(height: 18),
|
context.l10n.trackConvertTitle,
|
||||||
Text(
|
style: Theme.of(context).textTheme.titleLarge
|
||||||
context.l10n.trackConvertTitle,
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
),
|
||||||
fontWeight: FontWeight.bold,
|
const SizedBox(height: 4),
|
||||||
),
|
Text(
|
||||||
),
|
currentFormat,
|
||||||
const SizedBox(height: 4),
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
Text(
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
currentFormat,
|
),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
const SizedBox(height: 20),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
card(
|
card(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
sectionLabel(context.l10n.trackConvertTargetFormat),
|
sectionLabel(
|
||||||
Wrap(
|
context.l10n.trackConvertTargetFormat,
|
||||||
spacing: 8,
|
),
|
||||||
runSpacing: 8,
|
Wrap(
|
||||||
children: formats.map((format) {
|
spacing: 8,
|
||||||
return choice(
|
runSpacing: 8,
|
||||||
label: format,
|
children: formats.map((format) {
|
||||||
selected: format == selectedFormat,
|
return choice(
|
||||||
onTap: () {
|
label: format,
|
||||||
setSheetState(() {
|
selected: format == selectedFormat,
|
||||||
selectedFormat = format;
|
onTap: () {
|
||||||
isLosslessTarget =
|
setSheetState(() {
|
||||||
isLosslessConversionTarget(format);
|
selectedFormat = format;
|
||||||
if (!isLosslessTarget) {
|
isLosslessTarget =
|
||||||
selectedBitrate = defaultBitrateForFormat(
|
isLosslessConversionTarget(format);
|
||||||
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)
|
if (isLosslessTarget && sampleRateOptions.isNotEmpty)
|
||||||
card(
|
card(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
|
||||||
sectionLabel(context.l10n.audioAnalysisSampleRate),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
choice(
|
sectionLabel(
|
||||||
label: losslessSampleRateLabel(
|
context.l10n.audioAnalysisSampleRate,
|
||||||
null,
|
|
||||||
originalLabel: labels.original,
|
|
||||||
),
|
|
||||||
selected: selectedMaxSampleRate == null,
|
|
||||||
onTap: () => setSheetState(
|
|
||||||
() => selectedMaxSampleRate = null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
...sampleRateOptions.map((rate) {
|
Wrap(
|
||||||
return choice(
|
spacing: 8,
|
||||||
label: losslessSampleRateLabel(
|
runSpacing: 8,
|
||||||
rate,
|
children: [
|
||||||
originalLabel: labels.original,
|
choice(
|
||||||
|
label: losslessSampleRateLabel(
|
||||||
|
null,
|
||||||
|
originalLabel: labels.original,
|
||||||
|
),
|
||||||
|
selected: selectedMaxSampleRate == null,
|
||||||
|
onTap: () => setSheetState(() {
|
||||||
|
selectedMaxSampleRate = null;
|
||||||
|
selectedResampler = 'swr';
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
selected: rate == selectedMaxSampleRate,
|
...sampleRateOptions.map((rate) {
|
||||||
onTap: () => setSheetState(
|
return choice(
|
||||||
() => selectedMaxSampleRate = rate,
|
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),
|
|
||||||
),
|
if (isLosslessTarget && selectedMaxBitDepth != null)
|
||||||
child: Row(
|
card(
|
||||||
children: [
|
child: Column(
|
||||||
Icon(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Icons.verified,
|
children: [
|
||||||
size: 18,
|
sectionLabel(
|
||||||
color: colorScheme.primary,
|
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(
|
if (isLosslessTarget && selectedMaxSampleRate != null)
|
||||||
selectedMaxBitDepth == null &&
|
card(
|
||||||
selectedMaxSampleRate == null
|
child: Column(
|
||||||
? context.l10n.trackConvertLosslessHint
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
: context.l10n
|
children: [
|
||||||
.trackConvertLosslessOutputWithCap(
|
sectionLabel(
|
||||||
losslessQualityLabel(
|
context.l10n.trackConvertResampler,
|
||||||
LosslessConversionQuality(
|
),
|
||||||
maxBitDepth:
|
Wrap(
|
||||||
selectedMaxBitDepth,
|
spacing: 8,
|
||||||
maxSampleRate:
|
runSpacing: 8,
|
||||||
selectedMaxSampleRate,
|
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,
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
originalQualityLabel:
|
?.copyWith(color: colorScheme.primary),
|
||||||
labels.originalQuality,
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
label: Text(
|
||||||
),
|
isLosslessTarget
|
||||||
),
|
? context.l10n
|
||||||
|
.trackConvertActionLabelLossless(
|
||||||
const SizedBox(height: 8),
|
currentFormat,
|
||||||
SizedBox(
|
selectedFormat,
|
||||||
width: double.infinity,
|
losslessQualityLabel(
|
||||||
child: FilledButton.icon(
|
LosslessConversionQuality(
|
||||||
onPressed: () {
|
maxBitDepth: selectedMaxBitDepth,
|
||||||
Navigator.pop(context);
|
maxSampleRate:
|
||||||
_confirmAndConvert(
|
selectedMaxSampleRate,
|
||||||
context: this.context,
|
),
|
||||||
sourceFormat: currentFormat,
|
originalLabel: labels.original,
|
||||||
targetFormat: selectedFormat,
|
originalQualityLabel:
|
||||||
bitrate: selectedBitrate,
|
labels.originalQuality,
|
||||||
losslessQuality: LosslessConversionQuality(
|
),
|
||||||
maxBitDepth: selectedMaxBitDepth,
|
)
|
||||||
maxSampleRate: selectedMaxSampleRate,
|
: 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,
|
required String bitrate,
|
||||||
LosslessConversionQuality losslessQuality =
|
LosslessConversionQuality losslessQuality =
|
||||||
const LosslessConversionQuality(),
|
const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) {
|
}) {
|
||||||
final isLossless = isLosslessConversionTarget(targetFormat);
|
final isLossless = isLosslessConversionTarget(targetFormat);
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
@@ -4687,6 +4774,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
targetFormat: targetFormat,
|
targetFormat: targetFormat,
|
||||||
bitrate: bitrate,
|
bitrate: bitrate,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Text(dialogContext.l10n.trackConvertFormat),
|
child: Text(dialogContext.l10n.trackConvertFormat),
|
||||||
@@ -4702,6 +4790,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
required String bitrate,
|
required String bitrate,
|
||||||
LosslessConversionQuality losslessQuality =
|
LosslessConversionQuality losslessQuality =
|
||||||
const LosslessConversionQuality(),
|
const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) async {
|
}) async {
|
||||||
if (_isConverting) return;
|
if (_isConverting) return;
|
||||||
setState(() => _isConverting = true);
|
setState(() => _isConverting = true);
|
||||||
@@ -4778,6 +4868,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
deleteOriginal: !isSaf,
|
deleteOriginal: !isSaf,
|
||||||
sourceBitDepth: bitDepth,
|
sourceBitDepth: bitDepth,
|
||||||
losslessQuality: losslessQuality,
|
losslessQuality: losslessQuality,
|
||||||
|
losslessProcessing: losslessProcessing,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (coverPath != null) {
|
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,
|
required String codec,
|
||||||
int? targetBitDepth,
|
int? targetBitDepth,
|
||||||
int? targetSampleRate,
|
int? targetSampleRate,
|
||||||
|
LosslessConversionProcessing processing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) {
|
}) {
|
||||||
if (targetSampleRate != null && targetSampleRate > 0) {
|
final sampleFmt = _losslessOutputSampleFormat(
|
||||||
arguments
|
codec: codec,
|
||||||
..add('-ar')
|
targetBitDepth: targetBitDepth,
|
||||||
..add(targetSampleRate.toString());
|
);
|
||||||
}
|
_appendLosslessAresampleFilter(
|
||||||
|
arguments,
|
||||||
|
targetSampleRate: targetSampleRate,
|
||||||
|
outputSampleFormat: sampleFmt,
|
||||||
|
processing: processing,
|
||||||
|
);
|
||||||
if (targetBitDepth == null || targetBitDepth <= 0) return;
|
if (targetBitDepth == null || targetBitDepth <= 0) return;
|
||||||
|
|
||||||
if (codec == 'flac') {
|
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 {
|
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
|
|
||||||
@@ -2349,6 +2398,8 @@ class FFmpegService {
|
|||||||
int? sourceBitDepth,
|
int? sourceBitDepth,
|
||||||
LosslessConversionQuality losslessQuality =
|
LosslessConversionQuality losslessQuality =
|
||||||
const LosslessConversionQuality(),
|
const LosslessConversionQuality(),
|
||||||
|
LosslessConversionProcessing losslessProcessing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
}) async {
|
}) async {
|
||||||
final format = targetFormat.toLowerCase();
|
final format = targetFormat.toLowerCase();
|
||||||
if (!const {
|
if (!const {
|
||||||
@@ -2380,6 +2431,7 @@ class FFmpegService {
|
|||||||
coverPath: coverPath,
|
coverPath: coverPath,
|
||||||
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||||
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||||
|
processing: losslessProcessing,
|
||||||
deleteOriginal: deleteOriginal,
|
deleteOriginal: deleteOriginal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2391,6 +2443,7 @@ class FFmpegService {
|
|||||||
artistTagMode: artistTagMode,
|
artistTagMode: artistTagMode,
|
||||||
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||||
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||||
|
processing: losslessProcessing,
|
||||||
deleteOriginal: deleteOriginal,
|
deleteOriginal: deleteOriginal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2403,6 +2456,7 @@ class FFmpegService {
|
|||||||
sourceBitDepth: sourceBitDepth,
|
sourceBitDepth: sourceBitDepth,
|
||||||
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
|
||||||
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
|
||||||
|
processing: losslessProcessing,
|
||||||
deleteOriginal: deleteOriginal,
|
deleteOriginal: deleteOriginal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2500,6 +2554,8 @@ class FFmpegService {
|
|||||||
String? coverPath,
|
String? coverPath,
|
||||||
int? targetBitDepth,
|
int? targetBitDepth,
|
||||||
int? targetSampleRate,
|
int? targetSampleRate,
|
||||||
|
LosslessConversionProcessing processing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
final outputPath = _buildOutputPath(inputPath, '.m4a');
|
||||||
@@ -2539,6 +2595,7 @@ class FFmpegService {
|
|||||||
codec: 'alac',
|
codec: 'alac',
|
||||||
targetBitDepth: targetBitDepth,
|
targetBitDepth: targetBitDepth,
|
||||||
targetSampleRate: targetSampleRate,
|
targetSampleRate: targetSampleRate,
|
||||||
|
processing: processing,
|
||||||
);
|
);
|
||||||
arguments
|
arguments
|
||||||
..add('-map_metadata')
|
..add('-map_metadata')
|
||||||
@@ -2553,7 +2610,9 @@ class FFmpegService {
|
|||||||
_log.i(
|
_log.i(
|
||||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC'
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC'
|
||||||
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
|
'${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);
|
final result = await _executeWithArguments(arguments);
|
||||||
|
|
||||||
@@ -2583,6 +2642,8 @@ class FFmpegService {
|
|||||||
String artistTagMode = artistTagModeJoined,
|
String artistTagMode = artistTagModeJoined,
|
||||||
int? targetBitDepth,
|
int? targetBitDepth,
|
||||||
int? targetSampleRate,
|
int? targetSampleRate,
|
||||||
|
LosslessConversionProcessing processing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||||
@@ -2624,6 +2685,7 @@ class FFmpegService {
|
|||||||
codec: 'flac',
|
codec: 'flac',
|
||||||
targetBitDepth: targetBitDepth,
|
targetBitDepth: targetBitDepth,
|
||||||
targetSampleRate: targetSampleRate,
|
targetSampleRate: targetSampleRate,
|
||||||
|
processing: processing,
|
||||||
);
|
);
|
||||||
arguments
|
arguments
|
||||||
..add('-map_metadata')
|
..add('-map_metadata')
|
||||||
@@ -2642,7 +2704,9 @@ class FFmpegService {
|
|||||||
_log.i(
|
_log.i(
|
||||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC'
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC'
|
||||||
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
|
'${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);
|
final result = await _executeWithArguments(arguments);
|
||||||
|
|
||||||
@@ -2676,6 +2740,8 @@ class FFmpegService {
|
|||||||
int? sourceBitDepth,
|
int? sourceBitDepth,
|
||||||
int? targetBitDepth,
|
int? targetBitDepth,
|
||||||
int? targetSampleRate,
|
int? targetSampleRate,
|
||||||
|
LosslessConversionProcessing processing =
|
||||||
|
const LosslessConversionProcessing(),
|
||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final isAiff = container == 'aiff';
|
final isAiff = container == 'aiff';
|
||||||
@@ -2697,22 +2763,24 @@ class FFmpegService {
|
|||||||
inputPath,
|
inputPath,
|
||||||
'-map',
|
'-map',
|
||||||
'0:a',
|
'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(
|
_log.i(
|
||||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
|
'Converting ${inputPath.split(Platform.pathSeparator).last} to '
|
||||||
'${container.toUpperCase()} (${use24 ? 24 : 16}-bit'
|
'${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);
|
final result = await _executeWithArguments(arguments);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -19,6 +19,36 @@ const List<int> losslessConversionSampleRateOptions = [
|
|||||||
|
|
||||||
const List<int> losslessConversionBitDepthOptions = [16, 24];
|
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) {
|
List<int> availableLosslessBitDepthOptions(int? sourceBitDepth) {
|
||||||
if (sourceBitDepth == null || sourceBitDepth <= 0) {
|
if (sourceBitDepth == null || sourceBitDepth <= 0) {
|
||||||
return losslessConversionBitDepthOptions;
|
return losslessConversionBitDepthOptions;
|
||||||
@@ -189,12 +219,29 @@ extension LosslessConversionLabelsL10n on AppLocalizations {
|
|||||||
originalQuality: trackConvertOriginalQuality,
|
originalQuality: trackConvertOriginalQuality,
|
||||||
lossless: trackConvertLosslessSuffix,
|
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(
|
String losslessBitDepthLabel(int? bitDepth, {required String originalLabel}) {
|
||||||
int? bitDepth, {
|
|
||||||
required String originalLabel,
|
|
||||||
}) {
|
|
||||||
return bitDepth == null ? originalLabel : '$bitDepth-bit';
|
return bitDepth == null ? originalLabel : '$bitDepth-bit';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,10 +263,7 @@ String losslessQualityLabel(
|
|||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
if (quality.maxBitDepth != null) {
|
if (quality.maxBitDepth != null) {
|
||||||
parts.add(
|
parts.add(
|
||||||
losslessBitDepthLabel(
|
losslessBitDepthLabel(quality.maxBitDepth, originalLabel: originalLabel),
|
||||||
quality.maxBitDepth,
|
|
||||||
originalLabel: originalLabel,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (quality.maxSampleRate != null) {
|
if (quality.maxSampleRate != null) {
|
||||||
@@ -250,11 +294,7 @@ String convertedAudioQualityLabel({
|
|||||||
return '$upper ${losslessBitDepthLabel(actualBitDepth, originalLabel: labels.original)}/${losslessSampleRateLabel(actualSampleRate, originalLabel: labels.original)}';
|
return '$upper ${losslessBitDepthLabel(actualBitDepth, originalLabel: labels.original)}/${losslessSampleRateLabel(actualSampleRate, originalLabel: labels.original)}';
|
||||||
}
|
}
|
||||||
if (losslessQuality.hasCaps) {
|
if (losslessQuality.hasCaps) {
|
||||||
return '$upper ${losslessQualityLabel(
|
return '$upper ${losslessQualityLabel(losslessQuality, originalLabel: labels.original, originalQualityLabel: labels.originalQuality)}';
|
||||||
losslessQuality,
|
|
||||||
originalLabel: labels.original,
|
|
||||||
originalQualityLabel: labels.originalQuality,
|
|
||||||
)}';
|
|
||||||
}
|
}
|
||||||
return '$upper ${labels.lossless}';
|
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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -27,7 +33,11 @@ bool _containsHttpStatusCode(String message, String code) {
|
|||||||
message.contains('$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();
|
final normalizedExtensionId = extensionId.trim();
|
||||||
if (normalizedExtensionId.isEmpty) return false;
|
if (normalizedExtensionId.isEmpty) return false;
|
||||||
|
|
||||||
@@ -40,11 +50,9 @@ Future<bool> openPendingExtensionVerification(String extensionId) async {
|
|||||||
|
|
||||||
final uri = Uri.tryParse(authUrl);
|
final uri = Uri.tryParse(authUrl);
|
||||||
if (uri == null) return false;
|
if (uri == null) return false;
|
||||||
|
onAuthUri?.call(uri);
|
||||||
|
|
||||||
var launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
final launched = await _launchVerificationUrl(uri, browserMode);
|
||||||
if (!launched) {
|
|
||||||
launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (launched) {
|
if (launched) {
|
||||||
_log.i('Opened verification challenge for $normalizedExtensionId');
|
_log.i('Opened verification challenge for $normalizedExtensionId');
|
||||||
@@ -52,6 +60,12 @@ Future<bool> openPendingExtensionVerification(String extensionId) async {
|
|||||||
_log.w(
|
_log.w(
|
||||||
'Could not open verification challenge for $normalizedExtensionId',
|
'Could not open verification challenge for $normalizedExtensionId',
|
||||||
);
|
);
|
||||||
|
return showExtensionVerificationHelpDialog(
|
||||||
|
normalizedExtensionId,
|
||||||
|
uri,
|
||||||
|
browserMode: browserMode,
|
||||||
|
immediateFailure: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return launched;
|
return launched;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -61,3 +75,120 @@ Future<bool> openPendingExtensionVerification(String extensionId) async {
|
|||||||
return false;
|
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 format,
|
||||||
String bitrate,
|
String bitrate,
|
||||||
LosslessConversionQuality losslessQuality,
|
LosslessConversionQuality losslessQuality,
|
||||||
|
LosslessConversionProcessing losslessProcessing,
|
||||||
)
|
)
|
||||||
onConvert;
|
onConvert;
|
||||||
|
|
||||||
@@ -42,6 +43,8 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
late String _selectedBitrate;
|
late String _selectedBitrate;
|
||||||
int? _selectedMaxBitDepth;
|
int? _selectedMaxBitDepth;
|
||||||
int? _selectedMaxSampleRate;
|
int? _selectedMaxSampleRate;
|
||||||
|
String _selectedDither = 'none';
|
||||||
|
String _selectedResampler = 'swr';
|
||||||
|
|
||||||
String _defaultBitrateForFormat(String format) {
|
String _defaultBitrateForFormat(String format) {
|
||||||
if (format == 'Opus') return '128k';
|
if (format == 'Opus') return '128k';
|
||||||
@@ -70,242 +73,322 @@ class _BatchConvertSheetState extends State<BatchConvertSheet> {
|
|||||||
widget.sourceSampleRate,
|
widget.sourceSampleRate,
|
||||||
);
|
);
|
||||||
|
|
||||||
return SafeArea(
|
return Padding(
|
||||||
child: SingleChildScrollView(
|
padding: EdgeInsets.only(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
child: Column(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: DraggableScrollableSheet(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
initialChildSize: 0.85,
|
||||||
children: [
|
minChildSize: 0.5,
|
||||||
Center(
|
maxChildSize: 0.95,
|
||||||
child: Container(
|
expand: false,
|
||||||
width: 40,
|
builder: (context, scrollController) => SafeArea(
|
||||||
height: 4,
|
child: SingleChildScrollView(
|
||||||
decoration: BoxDecoration(
|
controller: scrollController,
|
||||||
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||||
borderRadius: BorderRadius.circular(2),
|
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(
|
||||||
const SizedBox(height: 18),
|
widget.title,
|
||||||
Text(
|
style: Theme.of(
|
||||||
widget.title,
|
context,
|
||||||
style: Theme.of(
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
context,
|
),
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
if (widget.subtitle != null) ...[
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
if (widget.subtitle != null) ...[
|
Text(
|
||||||
const SizedBox(height: 4),
|
widget.subtitle!,
|
||||||
Text(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
widget.subtitle!,
|
color: cs.onSurfaceVariant,
|
||||||
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: 20),
|
||||||
),
|
|
||||||
|
|
||||||
if (!_isLosslessTarget)
|
_card(
|
||||||
_card(
|
cs,
|
||||||
cs,
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
_sectionLabel(cs, context.l10n.trackConvertTargetFormat),
|
||||||
_sectionLabel(cs, context.l10n.trackConvertBitrate),
|
Wrap(
|
||||||
Wrap(
|
spacing: 8,
|
||||||
spacing: 8,
|
runSpacing: 8,
|
||||||
runSpacing: 8,
|
children: widget.formats.map((format) {
|
||||||
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) {
|
|
||||||
return _choice(
|
return _choice(
|
||||||
cs,
|
cs,
|
||||||
label: losslessBitDepthLabel(
|
label: format,
|
||||||
depth,
|
selected: format == _selectedFormat,
|
||||||
originalLabel: labels.original,
|
onTap: () {
|
||||||
),
|
setState(() {
|
||||||
selected: depth == _selectedMaxBitDepth,
|
_selectedFormat = format;
|
||||||
onTap: () =>
|
_isLosslessTarget = isLosslessConversionTarget(
|
||||||
setState(() => _selectedMaxBitDepth = depth),
|
format,
|
||||||
|
);
|
||||||
|
if (!_isLosslessTarget) {
|
||||||
|
_selectedBitrate = _defaultBitrateForFormat(
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_selectedMaxBitDepth = null;
|
||||||
|
_selectedMaxSampleRate = null;
|
||||||
|
_selectedDither = 'none';
|
||||||
|
_selectedResampler = 'swr';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}),
|
}).toList(),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
if (_isLosslessTarget && sampleRateOptions.isNotEmpty)
|
if (!_isLosslessTarget)
|
||||||
_card(
|
_card(
|
||||||
cs,
|
cs,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
|
||||||
_sectionLabel(cs, context.l10n.audioAnalysisSampleRate),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
children: [
|
||||||
_choice(
|
_sectionLabel(cs, context.l10n.trackConvertBitrate),
|
||||||
cs,
|
Wrap(
|
||||||
label: losslessSampleRateLabel(
|
spacing: 8,
|
||||||
null,
|
runSpacing: 8,
|
||||||
originalLabel: labels.original,
|
children: _bitrates.map((br) {
|
||||||
),
|
return _choice(
|
||||||
selected: _selectedMaxSampleRate == null,
|
cs,
|
||||||
onTap: () =>
|
label: br,
|
||||||
setState(() => _selectedMaxSampleRate = null),
|
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)
|
if (_isLosslessTarget && bitDepthOptions.isNotEmpty)
|
||||||
Container(
|
_card(
|
||||||
width: double.infinity,
|
cs,
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
horizontal: 14,
|
children: [
|
||||||
vertical: 12,
|
_sectionLabel(cs, context.l10n.audioAnalysisBitDepth),
|
||||||
),
|
Wrap(
|
||||||
decoration: BoxDecoration(
|
spacing: 8,
|
||||||
color: cs.primaryContainer.withValues(alpha: 0.4),
|
runSpacing: 8,
|
||||||
borderRadius: BorderRadius.circular(14),
|
children: [
|
||||||
),
|
_choice(
|
||||||
child: Row(
|
cs,
|
||||||
children: [
|
label: losslessBitDepthLabel(
|
||||||
Icon(Icons.verified, size: 18, color: cs.primary),
|
null,
|
||||||
const SizedBox(width: 8),
|
originalLabel: labels.original,
|
||||||
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(
|
selected: _selectedMaxBitDepth == null,
|
||||||
context,
|
onTap: () => setState(() {
|
||||||
).textTheme.bodySmall?.copyWith(color: cs.primary),
|
_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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
icon: const Icon(Icons.swap_horiz),
|
||||||
),
|
style: FilledButton.styleFrom(
|
||||||
),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
const SizedBox(height: 8),
|
borderRadius: BorderRadius.circular(14),
|
||||||
SizedBox(
|
),
|
||||||
width: double.infinity,
|
),
|
||||||
child: FilledButton.icon(
|
label: Text(widget.confirmLabel),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC using extension providers
|
description: Download Spotify tracks in FLAC using extension providers
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 4.7.0+136
|
version: 4.7.1+137
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user