Compare commits

...

60 Commits

Author SHA1 Message Date
zarzet 4974284760 fix(l10n): consolidate Crowdin locale files and fix ICU plural warnings
- Replace app_es-ES.arb, app_pt-PT.arb, app_tr-TR.arb (hyphen format)
  with properly named app_es_ES.arb, app_pt_PT.arb, app_tr.arb
- Fix @@locale values to match Flutter filename convention (underscore)
- Fix ICU plural syntax: remove redundant 'one {}' before '=1{...}'
  in es_ES, pt_PT, tr translations
- Regenerate l10n output files
2026-03-25 16:12:37 +07:00
Zarz Eleutherius a0306bd345 Merge pull request #258 from zarzet/l10n_dev
New Crowdin updates
2026-03-25 16:08:16 +07:00
zarzet ea7e594c68 Merge remote-tracking branch 'origin/dev' into l10n_dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
#	lib/l10n/arb/app_tr-TR.arb
2026-03-25 16:08:10 +07:00
Zarz Eleutherius d00a84f1b9 New translations app_en.arb (Indonesian) 2026-03-25 16:02:56 +07:00
Zarz Eleutherius 58b6203681 New translations app_en.arb (Chinese Simplified) 2026-03-25 16:02:54 +07:00
Zarz Eleutherius d299144c47 New translations app_en.arb (Russian) 2026-03-25 16:02:53 +07:00
Zarz Eleutherius 40b224e5a1 New translations app_en.arb (Dutch) 2026-03-25 16:02:51 +07:00
Zarz Eleutherius 7021e5493f New translations app_en.arb (Japanese) 2026-03-25 16:02:49 +07:00
Zarz Eleutherius 68bbc8a259 New translations app_en.arb (German) 2026-03-25 16:02:47 +07:00
zarzet be94a59441 chore: bump version to 3.9.0+115, add new translators
- Bump app version from 3.8.8 to 3.9.0 (build 115)
- Add 4 new Crowdin translators: unkn0wn (Indonesian), lunching1272
  (Chinese Simplified), Сергей Ильченко (Russian), Girl-lass (Chinese
  Simplified)
2026-03-25 15:47:08 +07:00
zarzet 3a73aee1b7 feat: add home feed provider setting, fix Qobuz cover URL propagation
- Add homeFeedProvider field to AppSettings with picker UI in extensions page
- Update explore_provider to respect user's home feed provider preference
- Add normalizeCoverReference() and normalizeRemoteHttpUrl() to filter
  invalid cover URLs (no scheme, no host, protocol-relative)
- Apply cover URL normalization across all screens and providers to
  prevent 'no host specified in URI' errors from Qobuz
- Propagate CoverURL from QobuzDownloadResult through Go backend so
  cover art is available even when request metadata is incomplete
2026-03-25 15:46:22 +07:00
zarzet c91154ea3e feat: add built-in search provider in settings, fix bottom sheet overflow 2026-03-25 15:46:12 +07:00
zarzet 4f365ca7fe feat: add built-in Tidal/Qobuz search with recommended service picker
- Add SearchAll() for Tidal and Qobuz in Go backend (tracks, artists, albums)
- Add searchTidalAll/searchQobuzAll platform routing for Android and iOS
- Add Tidal/Qobuz options to search provider dropdown in home tab
- Show (Recommended) label and auto-select service in download picker
2026-03-25 13:52:57 +07:00
zarzet 98fdc0ed7c feat: restore Tidal HIGH (AAC 320kbps) lossy quality option (closes #242)
Requested by @okinaau in issue #242 — brings back the ability to
download tracks in lossy format for users on low storage devices.

HIGH quality fetches the AAC M4A stream directly from the Tidal server
(no lossless download + re-encode), then converts to MP3 or Opus via
FFmpeg based on the tidalHighFormat setting (mp3_320, opus_256, or
opus_128).

- go_backend/tidal.go: restore outputExt .m4a, filename logic,
  duplicate-check guard, HIGH M4A lyrics/LRC handling, and
  bitDepth=0/sampleRate=44100 for HIGH quality result
- settings.dart + settings.g.dart: re-add tidalHighFormat field
  (default mp3_320) with JSON serialization
- settings_provider.dart: re-add setTidalHighFormat(), remove
  migration that force-migrated HIGH to LOSSLESS
- download_queue_provider.dart: restore HIGH conversion logic for
  both SAF and non-SAF paths using FFmpegService.convertM4aToLossy
- download_settings_page.dart: restore Lossy 320kbps quality tile,
  format sub-picker tile, _getTidalHighFormatLabel helper, and
  _showTidalHighFormatPicker bottom sheet
- l10n: add 10 keys (downloadLossy320, downloadLossyFormat,
  downloadLossy320Format, downloadLossy320FormatDesc, downloadLossyMp3,
  downloadLossyMp3Subtitle, downloadLossyOpus256/Subtitle,
  downloadLossyOpus128/Subtitle) to ARB and all 13 generated files
2026-03-22 23:33:32 +07:00
zarzet 12be560cb8 feat: add M4A metadata/cover embed support across all Flutter screens
Add FFmpegService.embedMetadataToM4a() for writing tags and cover art
into M4A files via FFmpeg. Fix two bugs in the same function:
- Remove '-disposition:v:0 attached_pic' which is only valid for
  Matroska/WebM containers and causes FFmpeg to error on MP4/M4A
- Apply same fix to _convertToAlac which had the identical issue

Add M4A handling (isM4A branch) to all four embed call-sites:
track_metadata_screen (lyrics embed, re-enrich, edit metadata sheet,
format conversion), queue_tab, local_album_screen, and
downloaded_album_screen.

Add 'LYRICS'/'UNSYNCEDLYRICS' to _mapMetadataForTagEmbed so existing
lyrics survive a re-enrich cycle on M4A/MP3/Opus files.

Preserve existing lyrics before overwriting tags in the edit metadata
sheet (best-effort readFileMetadata before FFmpeg pass).

Extract mergePlatformMetadataForTagEmbed() into lyrics_metadata_helper
to deduplicate the identical metadata-mapping loops that existed in
queue_tab, local_album_screen, downloaded_album_screen, and
track_metadata_screen.

Wire ensureLyricsMetadataForConversion into the format conversion path
in track_metadata_screen so lyrics are carried through conversions.

Add ISRC and LABEL/ORGANIZATION mappings to _convertToM4aTags.
2026-03-22 23:01:32 +07:00
zarzet 4cf885a52e feat: populate M4A metadata in ReadFileMetadata and library scan
ReadFileMetadata now fills all tag fields (title, artist, album, ISRC,
lyrics, genre, label, copyright, composer, comment, track/disc number)
for M4A files using the new ReadM4ATags helper, matching the existing
behavior for FLAC, MP3, and Ogg.

scanM4AFile reads tags via ReadM4ATags instead of falling back to the
filename, and applies applyDefaultLibraryMetadata for missing fields
(consistent with FLAC/MP3 scan path).

Remove the '&& ext != ".m4a"' guard in cover cache so M4A cover art
is extracted and cached during library scans.
2026-03-22 23:00:55 +07:00
zarzet c57c8a4267 feat: implement full M4A tag read engine with atom path fallback and freeform fix
Add ReadM4ATags() that parses all standard iTunes atoms (title, artist,
album, album artist, date, genre, composer, comment, copyright, lyrics,
track/disc number) and freeform '----' atoms (ISRC, label, lyrics).

Fix two pre-existing bugs in the M4A atom traversal:
- findM4AIlstAtom: now tries moov>udta>meta>ilst first, then falls back
  to moov>meta>ilst so files from Tidal/Qobuz/Apple Music are handled
- readM4AFreeformValue: 'name' atom payload is raw UTF-8 after 4-byte
  flags, not a nested 'data' atom; fix reads it directly so ISRC/label
  freeform tags are no longer silently dropped

Refactor extractLyricsFromM4A and extractCoverFromM4A to reuse the new
helpers (findM4AIlstAtom, readM4ADataAtomPayload) instead of duplicating
the atom traversal logic. Add extractAnyCoverArtWithHint M4A case that
previously returned a hardcoded 'not yet supported' error.
2026-03-22 23:00:42 +07:00
zarzet 497ba342c0 feat: add createPlaylistFolder setting for playlist source folder prefix
When enabled, playlist downloads are placed inside a subfolder named
after the playlist before the normal folder organization structure
(e.g. Playlist/<artist>/<album>/). The setting is a no-op when folder
organization is already set to 'By Playlist'. Includes model field,
JSON serialization, settings notifier, download queue path logic,
UI toggle in download settings, and localizations for all 13 languages.
2026-03-22 22:43:03 +07:00
zarzet aca0bbb819 chore: remove security_hardening_test.go
Tests for sanitizeSensitiveLogText, validateExtensionAuthURL,
validateDomain, and buildStoreExtensionDestPath are no longer
maintained alongside the main source and have been removed.
2026-03-22 22:42:50 +07:00
zarzet 2df8fd6282 feat: add normalizeLooseArtistName with diacritic folding for resilient artist matching
Use Unicode NFD decomposition to strip combining marks so variants like
"Özkent" and "Ozkent" are treated as equivalent. Apply the new helper
in both tidal.go and qobuz.go artistsMatch functions.
2026-03-22 22:42:33 +07:00
Zarz Eleutherius cbfa147a12 New translations app_en.arb (Turkish) 2026-03-11 23:43:01 +07:00
Zarz Eleutherius 5b8c953ae6 New translations app_en.arb (Hindi) 2026-03-11 23:43:00 +07:00
Zarz Eleutherius 37a4dc096b New translations app_en.arb (Indonesian) 2026-03-11 23:42:58 +07:00
Zarz Eleutherius b3808645fb New translations app_en.arb (Chinese Traditional) 2026-03-11 23:42:57 +07:00
Zarz Eleutherius 24aa804bf2 New translations app_en.arb (Chinese Simplified) 2026-03-11 23:42:56 +07:00
Zarz Eleutherius 941ffb2bb7 New translations app_en.arb (Russian) 2026-03-11 23:42:54 +07:00
Zarz Eleutherius 59737d6f2b New translations app_en.arb (Portuguese) 2026-03-11 23:42:53 +07:00
Zarz Eleutherius c8ad93ee9b New translations app_en.arb (Dutch) 2026-03-11 23:42:52 +07:00
Zarz Eleutherius 8cb0c037c2 New translations app_en.arb (Korean) 2026-03-11 23:42:50 +07:00
Zarz Eleutherius e30b69397b New translations app_en.arb (Japanese) 2026-03-11 23:42:49 +07:00
Zarz Eleutherius d6e837fd61 New translations app_en.arb (German) 2026-03-11 23:42:47 +07:00
Zarz Eleutherius 5c97d202b9 New translations app_en.arb (Spanish) 2026-03-11 23:42:46 +07:00
Zarz Eleutherius 0f6cfa75bb New translations app_en.arb (French) 2026-03-11 23:42:44 +07:00
Zarz Eleutherius 91bd6d1572 Update source file app_en.arb 2026-03-11 23:42:42 +07:00
Zarz Eleutherius dd05061829 New translations app_en.arb (Turkish) 2026-03-10 23:26:30 +07:00
Zarz Eleutherius 8f6b99c550 New translations app_en.arb (Hindi) 2026-03-10 23:26:29 +07:00
Zarz Eleutherius f54ee86591 New translations app_en.arb (Indonesian) 2026-03-10 23:26:27 +07:00
Zarz Eleutherius 42e0ec2663 New translations app_en.arb (Chinese Traditional) 2026-03-10 23:26:26 +07:00
Zarz Eleutherius 0456a97b35 New translations app_en.arb (Chinese Simplified) 2026-03-10 23:26:24 +07:00
Zarz Eleutherius 07c609cc3a New translations app_en.arb (Russian) 2026-03-10 23:26:23 +07:00
Zarz Eleutherius de5d26403f New translations app_en.arb (Portuguese) 2026-03-10 23:26:22 +07:00
Zarz Eleutherius 73c2d0efac New translations app_en.arb (Dutch) 2026-03-10 23:26:20 +07:00
Zarz Eleutherius d3c1c440cc New translations app_en.arb (Korean) 2026-03-10 23:26:19 +07:00
Zarz Eleutherius 94195c636f New translations app_en.arb (Japanese) 2026-03-10 23:26:17 +07:00
Zarz Eleutherius 9abf492362 New translations app_en.arb (German) 2026-03-10 23:26:16 +07:00
Zarz Eleutherius defc84c216 New translations app_en.arb (Spanish) 2026-03-10 23:26:15 +07:00
Zarz Eleutherius 3c9ae39145 New translations app_en.arb (French) 2026-03-10 23:26:13 +07:00
Zarz Eleutherius 581f43f4c1 New translations app_en.arb (Turkish) 2026-03-09 22:45:36 +07:00
Zarz Eleutherius 221d7e4829 New translations app_en.arb (Hindi) 2026-03-09 22:45:35 +07:00
Zarz Eleutherius 706528f04b New translations app_en.arb (Indonesian) 2026-03-09 22:45:33 +07:00
Zarz Eleutherius f95a96dd1f New translations app_en.arb (Chinese Traditional) 2026-03-09 22:45:32 +07:00
Zarz Eleutherius d85c16ce0f New translations app_en.arb (Chinese Simplified) 2026-03-09 22:45:31 +07:00
Zarz Eleutherius 35afdf4be4 New translations app_en.arb (Russian) 2026-03-09 22:45:30 +07:00
Zarz Eleutherius eb5ed86019 New translations app_en.arb (Portuguese) 2026-03-09 22:45:28 +07:00
Zarz Eleutherius 0cfa6f56be New translations app_en.arb (Dutch) 2026-03-09 22:45:27 +07:00
Zarz Eleutherius 5af88ead33 New translations app_en.arb (Korean) 2026-03-09 22:45:25 +07:00
Zarz Eleutherius 8ec63ee610 New translations app_en.arb (Japanese) 2026-03-09 22:45:24 +07:00
Zarz Eleutherius c8247bf7a0 New translations app_en.arb (German) 2026-03-09 22:45:22 +07:00
Zarz Eleutherius 2f3270c7ff New translations app_en.arb (Spanish) 2026-03-09 22:45:21 +07:00
Zarz Eleutherius 960d60f0bc New translations app_en.arb (French) 2026-03-09 22:45:19 +07:00
73 changed files with 7167 additions and 1466 deletions
@@ -2642,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Tidal search API
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
// Qobuz search API
"searchQobuzAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"getDeezerRelatedArtists" -> { "getDeezerRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: "" val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12 val limit = call.argument<Int>("limit") ?: 12
+13 -1
View File
@@ -1594,7 +1594,19 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
return extractOggCoverArt(filePath) return extractOggCoverArt(filePath)
case ".m4a": case ".m4a":
return nil, "", fmt.Errorf("M4A cover extraction not yet supported") data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
default: default:
return nil, "", fmt.Errorf("unsupported format: %s", ext) return nil, "", fmt.Errorf("unsupported format: %s", ext)
+67 -3
View File
@@ -128,6 +128,7 @@ type DownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
CoverURL string
Genre string Genre string
Label string Label string
Copyright string Copyright string
@@ -214,6 +215,11 @@ func buildDownloadSuccessResponse(
copyright = req.Copyright copyright = req.Copyright
} }
coverURL := strings.TrimSpace(result.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(req.CoverURL)
}
return DownloadResponse{ return DownloadResponse{
Success: true, Success: true,
Message: message, Message: message,
@@ -230,7 +236,7 @@ func buildDownloadSuccessResponse(
TrackNumber: trackNumber, TrackNumber: trackNumber,
DiscNumber: discNumber, DiscNumber: discNumber,
ISRC: isrc, ISRC: isrc,
CoverURL: req.CoverURL, CoverURL: coverURL,
Genre: genre, Genre: genre,
Label: label, Label: label,
Copyright: copyright, Copyright: copyright,
@@ -378,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC, ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC, LyricsLRC: qobuzResult.LyricsLRC,
} }
} }
@@ -586,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC, ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC, LyricsLRC: qobuzResult.LyricsLRC,
} }
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) { } else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
@@ -739,6 +747,26 @@ func ReadFileMetadata(filePath string) (string, error) {
} }
} }
} else if isM4A { } else if isM4A {
meta, err := ReadM4ATags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["label"] = meta.Label
result["copyright"] = meta.Copyright
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetM4AQuality(filePath) quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil { if qualityErr == nil {
result["bit_depth"] = quality.BitDepth result["bit_depth"] = quality.BitDepth
@@ -1127,6 +1155,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewQobuzDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel() defer cancel()
@@ -1960,8 +2018,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
} }
}() }()
// Fetch lyrics // Preserve existing lyrics when online enrichment does not return a replacement.
var lyricsLRC string var lyricsLRC string
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
lyricsLRC = existingLyrics
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
}
// Fetch lyrics
if req.EmbedLyrics { if req.EmbedLyrics {
client := NewLyricsClient() client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0 durationSec := float64(req.DurationMs) / 1000.0
@@ -2042,7 +2107,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// MP3/Opus: return metadata map for Dart to use FFmpeg
// Don't cleanup cover temp — Dart needs it for FFmpeg embed // Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false cleanupCover = false
result := map[string]interface{}{ result := map[string]interface{}{
+29
View File
@@ -84,3 +84,32 @@ func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
t.Fatalf("disc number = %d", discNumber) t.Fatalf("disc number = %d", discNumber)
} }
} }
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
Album: "Album",
CoverURL: "https://cdn.qobuz.test/cover.jpg",
}
resp := buildDownloadSuccessResponse(
req,
result,
"qobuz",
"ok",
"/tmp/test.flac",
false,
)
if resp.CoverURL != result.CoverURL {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
}
}
+2
View File
@@ -1480,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC, ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
} }
} }
err = qobuzErr err = qobuzErr
@@ -1522,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC, ISRC: result.ISRC,
CoverURL: result.CoverURL,
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
+19 -2
View File
@@ -293,7 +293,7 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
libraryCoverCacheMu.RLock() libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock() libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" && ext != ".m4a" { if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir) coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
if err == nil && coverPath != "" { if err == nil && coverPath != "" {
result.CoverPath = coverPath result.CoverPath = coverPath
@@ -373,13 +373,30 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
} }
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath)
if err == nil && metadata != nil {
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
result.AlbumArtist = metadata.AlbumArtist
result.ISRC = metadata.ISRC
result.TrackNumber = metadata.TrackNumber
result.DiscNumber = metadata.DiscNumber
result.ReleaseDate = metadata.Date
if result.ReleaseDate == "" {
result.ReleaseDate = metadata.Year
}
result.Genre = metadata.Genre
}
quality, err := GetM4AQuality(filePath) quality, err := GetM4AQuality(filePath)
if err == nil { if err == nil {
result.BitDepth = quality.BitDepth result.BitDepth = quality.BitDepth
result.SampleRate = quality.SampleRate result.SampleRate = quality.SampleRate
} }
return scanFromFilename(filePath, "", result) applyDefaultLibraryMetadata(filePath, "", result)
return result, nil
} }
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
+237 -91
View File
@@ -589,78 +589,117 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath) return extractLyricsFromSidecarLRC(filePath)
} }
func extractLyricsFromM4A(filePath string) (string, error) { func ReadM4ATags(filePath string) (*AudioMetadata, error) {
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
return "", err return nil, err
} }
defer f.Close() defer f.Close()
fi, err := f.Stat() fi, err := f.Stat()
if err != nil {
return nil, err
}
ilst, err := findM4AIlstAtom(f, fi.Size())
if err != nil {
return nil, err
}
metadata := &AudioMetadata{}
start := ilst.offset + ilst.headerSize
end := ilst.offset + ilst.size
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fi.Size())
if err != nil {
return nil, err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "\xa9nam":
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9ART":
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9alb":
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
case "aART":
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9day":
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
metadata.Year = metadata.Date
case "\xa9gen":
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9wrt":
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9cmt":
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
case "cprt":
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
case "\xa9lyr":
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
case "trkn":
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
case "disk":
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
case "----":
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
if freeformErr == nil {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "ISRC":
metadata.ISRC = value
case "LABEL", "ORGANIZATION":
metadata.Label = value
case "COMMENT":
if metadata.Comment == "" {
metadata.Comment = value
}
case "COMPOSER":
if metadata.Composer == "" {
metadata.Composer = value
}
case "COPYRIGHT":
if metadata.Copyright == "" {
metadata.Copyright = value
}
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
}
}
}
pos += header.size
}
if metadata.Title == "" &&
metadata.Artist == "" &&
metadata.Album == "" &&
metadata.AlbumArtist == "" &&
metadata.Lyrics == "" &&
metadata.TrackNumber == 0 &&
metadata.DiscNumber == 0 {
return nil, fmt.Errorf("no M4A tags found")
}
return metadata, nil
}
func extractLyricsFromM4A(filePath string) (string, error) {
metadata, err := ReadM4ATags(filePath)
if err != nil { if err != nil {
return "", err return "", err
} }
fileSize := fi.Size() if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
return "", fmt.Errorf("no lyrics found in file")
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return "", fmt.Errorf("moov not found")
} }
return metadata.Lyrics, nil
bodyStart := moov.offset + moov.headerSize
bodySize := moov.size - moov.headerSize
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
if err != nil || !found {
return "", fmt.Errorf("udta not found")
}
bodyStart = udta.offset + udta.headerSize
bodySize = udta.size - udta.headerSize
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
if err != nil || !found {
return "", fmt.Errorf("meta not found")
}
// meta atom has 4-byte version/flags after the header
bodyStart = meta.offset + meta.headerSize + 4
bodySize = meta.size - meta.headerSize - 4
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
if err != nil || !found {
return "", fmt.Errorf("ilst not found")
}
bodyStart = ilst.offset + ilst.headerSize
bodySize = ilst.size - ilst.headerSize
lyr, found, err := findAtomInRange(f, bodyStart, bodySize, "\xa9lyr", fileSize)
if err != nil || !found {
return "", fmt.Errorf("lyrics atom not found")
}
dataStart := lyr.offset + lyr.headerSize
dataSize := lyr.size - lyr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return "", fmt.Errorf("data atom not found in lyrics")
}
// data atom: 8 bytes header + 4 bytes type indicator + 4 bytes locale = skip 8
textStart := dataAtom.offset + dataAtom.headerSize + 8
textLen := dataAtom.size - dataAtom.headerSize - 8
if textLen <= 0 {
return "", fmt.Errorf("empty lyrics")
}
buf := make([]byte, textLen)
if _, err := f.ReadAt(buf, textStart); err != nil {
return "", err
}
return string(buf), nil
} }
func extractCoverFromM4A(filePath string) ([]byte, error) { func extractCoverFromM4A(filePath string) ([]byte, error) {
@@ -676,37 +715,13 @@ func extractCoverFromM4A(filePath string) ([]byte, error) {
} }
fileSize := fi.Size() fileSize := fi.Size()
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize) ilst, err := findM4AIlstAtom(f, fileSize)
if err != nil || !found { if err != nil {
return nil, fmt.Errorf("moov not found") return nil, err
} }
bodyStart := moov.offset + moov.headerSize bodyStart := ilst.offset + ilst.headerSize
bodySize := moov.size - moov.headerSize bodySize := ilst.size - ilst.headerSize
udta, found, err := findAtomInRange(f, bodyStart, bodySize, "udta", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("udta not found")
}
bodyStart = udta.offset + udta.headerSize
bodySize = udta.size - udta.headerSize
meta, found, err := findAtomInRange(f, bodyStart, bodySize, "meta", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("meta not found")
}
bodyStart = meta.offset + meta.headerSize + 4
bodySize = meta.size - meta.headerSize - 4
ilst, found, err := findAtomInRange(f, bodyStart, bodySize, "ilst", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("ilst not found")
}
bodyStart = ilst.offset + ilst.headerSize
bodySize = ilst.size - ilst.headerSize
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize) covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
if err != nil || !found { if err != nil || !found {
@@ -736,6 +751,137 @@ func extractCoverFromM4A(filePath string) ([]byte, error) {
return buf, nil return buf, nil
} }
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
// It tries two common layouts:
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return atomHeader{}, fmt.Errorf("moov not found")
}
moovBodyStart := moov.offset + moov.headerSize
moovBodySize := moov.size - moov.headerSize
// Path 1: moov > udta > meta > ilst
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
udtaBodyStart := udta.offset + udta.headerSize
udtaBodySize := udta.size - udta.headerSize
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
return ilst, nil
}
}
}
// Path 2: moov > meta > ilst (no udta wrapper)
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
metaBodyStart := meta.offset + meta.headerSize + 4
metaBodySize := meta.size - meta.headerSize - 4
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
return ilst, nil
}
}
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
}
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
payloadLen := dataAtom.size - dataAtom.headerSize - 8
if payloadLen <= 0 {
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
}
buf := make([]byte, payloadLen)
if _, err := f.ReadAt(buf, payloadStart); err != nil {
return nil, err
}
return buf, nil
}
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
dataStart := parent.offset + parent.headerSize
dataSize := parent.size - parent.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
}
return readM4ADataAtomPayload(f, dataAtom)
}
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return "", err
}
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
}
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
payload, err := readM4ADataPayload(f, parent, fileSize)
if err != nil {
return 0, err
}
if len(payload) < 4 {
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
}
return int(binary.BigEndian.Uint16(payload[2:4])), nil
}
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
start := parent.offset + parent.headerSize
end := parent.offset + parent.size
var nameValue string
var dataValue string
for pos := start; pos+8 <= end; {
header, err := readAtomHeaderAt(f, pos, fileSize)
if err != nil {
return "", "", err
}
if header.size == 0 {
header.size = end - pos
}
if header.size < header.headerSize {
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
}
switch header.typ {
case "mean":
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
case "name":
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
// It does NOT contain a nested "data" atom, so read the payload directly.
payloadStart := header.offset + header.headerSize + 4
payloadLen := header.size - header.headerSize - 4
if payloadLen > 0 {
buf := make([]byte, payloadLen)
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
}
}
case "data":
payload, payloadErr := readM4ADataAtomPayload(f, header)
if payloadErr == nil {
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
}
}
pos += header.size
}
if nameValue == "" || dataValue == "" {
return "", "", fmt.Errorf("freeform M4A tag incomplete")
}
return nameValue, dataValue, nil
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) { func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath) ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext) base := strings.TrimSuffix(filePath, ext)
+136 -3
View File
@@ -479,8 +479,8 @@ func parseQobuzURL(input string) (string, string, error) {
} }
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normExpected := normalizeLooseArtistName(expectedArtist)
normFound := strings.ToLower(strings.TrimSpace(foundArtist)) normFound := normalizeLooseArtistName(foundArtist)
if normExpected == normFound { if normExpected == normFound {
return true return true
@@ -1307,6 +1307,134 @@ func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
return results, nil return results, nil
} }
// SearchAll searches Qobuz for tracks, artists, and albums matching the query.
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" {
return nil, fmt.Errorf("empty qobuz search query")
}
albumLimit := 5
if filter != "" {
switch filter {
case "track":
trackLimit = 50
artistLimit = 0
albumLimit = 0
case "artist":
trackLimit = 0
artistLimit = 20
albumLimit = 0
case "album":
trackLimit = 0
artistLimit = 0
albumLimit = 20
}
}
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0),
}
if trackLimit > 0 {
tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit)
if err != nil {
GoLog("[Qobuz] Track search failed: %v\n", err)
return nil, fmt.Errorf("qobuz track search failed: %w", err)
}
GoLog("[Qobuz] Got %d tracks from API\n", len(tracks))
for i := range tracks {
result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i]))
}
}
if artistLimit > 0 {
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s",
url.QueryEscape(cleanQuery), artistLimit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err == nil {
resp, reqErr := DoRequestWithUserAgent(q.client, req)
if reqErr == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
var artistResp struct {
Artists struct {
Items []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image qobuzImageSet `json:"image"`
} `json:"items"`
} `json:"artists"`
}
if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil {
GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items))
for _, artist := range artistResp.Artists.Items {
imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
result.Artists = append(result.Artists, SearchArtistResult{
ID: qobuzPrefixedNumericID(artist.ID),
Name: strings.TrimSpace(artist.Name),
Images: imageURL,
})
}
} else {
GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
}
}
} else {
GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
}
}
}
if albumLimit > 0 {
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
url.QueryEscape(cleanQuery), albumLimit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err == nil {
resp, reqErr := DoRequestWithUserAgent(q.client, req)
if reqErr == nil {
defer resp.Body.Close()
if resp.StatusCode == 200 {
var albumResp struct {
Albums struct {
Items []qobuzAlbumDetails `json:"items"`
} `json:"albums"`
}
if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil {
GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items))
for i := range albumResp.Albums.Items {
album := &albumResp.Albums.Items[i]
result.Albums = append(result.Albums, SearchAlbumResult{
ID: qobuzPrefixedID(album.ID),
Name: strings.TrimSpace(album.Title),
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
Images: qobuzAlbumImage(album),
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
TotalTracks: album.TracksCount,
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
})
}
} else {
GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
}
}
} else {
GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
}
}
}
GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
return result, nil
}
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
queries := []string{} queries := []string{}
@@ -1939,6 +2067,7 @@ type QobuzDownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
CoverURL string
LyricsLRC string LyricsLRC string
} }
@@ -2132,7 +2261,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
parallelDone := make(chan struct{}) parallelDone := make(chan struct{})
go func() { go func() {
defer close(parallelDone) defer close(parallelDone)
coverURL := req.CoverURL coverURL := strings.TrimSpace(req.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
}
embedLyrics := req.EmbedLyrics embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata { if !req.EmbedMetadata {
coverURL = "" coverURL = ""
@@ -2265,6 +2397,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: resultTrackNumber, TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber, DiscNumber: resultDiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
LyricsLRC: lyricsLRC, LyricsLRC: lyricsLRC,
}, nil }, nil
} }
-80
View File
@@ -1,80 +0,0 @@
package gobackend
import (
"path/filepath"
"strings"
"testing"
)
func TestSanitizeSensitiveLogText(t *testing.T) {
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
redacted := sanitizeSensitiveLogText(input)
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
}
if !strings.Contains(redacted, "[REDACTED]") {
t.Fatalf("expected redaction marker in output, got: %s", redacted)
}
}
func TestValidateExtensionAuthURL(t *testing.T) {
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
t.Fatalf("expected valid auth URL, got error: %v", err)
}
blocked := []string{
"http://accounts.example.com/oauth/authorize",
"https://user:pass@accounts.example.com/oauth/authorize",
"https://localhost/oauth/authorize",
}
for _, rawURL := range blocked {
if err := validateExtensionAuthURL(rawURL); err == nil {
t.Fatalf("expected URL to be blocked: %s", rawURL)
}
}
}
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
t.Fatal("expected embedded URL credentials to be rejected")
}
}
func TestBuildStoreExtensionDestPath(t *testing.T) {
baseDir := t.TempDir()
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
if err != nil {
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
}
if !isPathWithinBase(baseDir, destPath) {
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
}
baseName := filepath.Base(destPath)
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
}
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
}
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
t.Fatal("expected empty extension id to be rejected")
}
}
+152 -7
View File
@@ -874,6 +874,121 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
return results, nil return results, nil
} }
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" {
return nil, fmt.Errorf("empty tidal search query")
}
albumLimit := 5
if filter != "" {
switch filter {
case "track":
trackLimit = 50
artistLimit = 0
albumLimit = 0
case "artist":
trackLimit = 0
artistLimit = 20
albumLimit = 0
case "album":
trackLimit = 0
artistLimit = 0
albumLimit = 20
}
}
result := &SearchAllResult{
Tracks: make([]TrackMetadata, 0, trackLimit),
Artists: make([]SearchArtistResult, 0, artistLimit),
Albums: make([]SearchAlbumResult, 0, albumLimit),
Playlists: make([]SearchPlaylistResult, 0),
}
if trackLimit > 0 {
page, err := t.getTrackSearchPage(cleanQuery, trackLimit)
if err != nil {
GoLog("[Tidal] Track search failed: %v\n", err)
return nil, fmt.Errorf("tidal track search failed: %w", err)
}
GoLog("[Tidal] Got %d tracks from API\n", len(page.Items))
for i := range page.Items {
result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i]))
}
}
if artistLimit > 0 {
requestURL := tidalBuildMetadataURL("search/artists", url.Values{
"query": {cleanQuery},
"limit": {strconv.Itoa(artistLimit)},
"offset": {"0"},
})
var artistResp struct {
Items []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Picture string `json:"picture"`
Popularity int `json:"popularity"`
URL string `json:"url"`
} `json:"items"`
}
if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil {
GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items))
for _, artist := range artistResp.Items {
result.Artists = append(result.Artists, SearchArtistResult{
ID: tidalPrefixedNumericID(artist.ID),
Name: strings.TrimSpace(artist.Name),
Images: tidalImageURL(artist.Picture, "750x750"),
Followers: 0,
Popularity: artist.Popularity,
})
}
} else {
GoLog("[Tidal] Artist search failed: %v\n", err)
}
}
if albumLimit > 0 {
requestURL := tidalBuildMetadataURL("search/albums", url.Values{
"query": {cleanQuery},
"limit": {strconv.Itoa(albumLimit)},
"offset": {"0"},
})
var albumResp struct {
Items []tidalPublicAlbum `json:"items"`
}
if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil {
GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items))
for i := range albumResp.Items {
album := &albumResp.Items[i]
albumType := strings.ToLower(strings.TrimSpace(album.Type))
if albumType == "" {
albumType = "album"
}
result.Albums = append(result.Albums, SearchAlbumResult{
ID: tidalPrefixedNumericID(album.ID),
Name: strings.TrimSpace(album.Title),
Artists: tidalAlbumArtistsDisplay(album),
Images: tidalImageURL(album.Cover, "1280x1280"),
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
TotalTracks: album.NumberOfTracks,
AlbumType: albumType,
})
}
} else {
GoLog("[Tidal] Album search failed: %v\n", err)
}
}
GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
return result, nil
}
func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
track, err := t.getPublicTrack(resourceID) track, err := t.getPublicTrack(resourceID)
if err != nil { if err != nil {
@@ -1629,8 +1744,8 @@ type TidalDownloadResult struct {
} }
func artistsMatch(spotifyArtist, tidalArtist string) bool { func artistsMatch(spotifyArtist, tidalArtist string) bool {
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist)) normSpotify := normalizeLooseArtistName(spotifyArtist)
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist)) normTidal := normalizeLooseArtistName(tidalArtist)
if normSpotify == normTidal { if normSpotify == normTidal {
return true return true
@@ -2109,7 +2224,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
outputExt := strings.TrimSpace(req.OutputExt) outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" { if outputExt == "" {
outputExt = ".flac" if quality == "HIGH" {
outputExt = ".m4a"
} else {
outputExt = ".flac"
}
} else if !strings.HasPrefix(outputExt, ".") { } else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt outputExt = "." + outputExt
} }
@@ -2123,7 +2242,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
m4aPath = outputPath m4aPath = outputPath
} else { } else {
if outputExt == ".m4a" { if outputExt == ".m4a" || quality == "HIGH" {
filename = sanitizeFilename(filename) + ".m4a" filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename) outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath m4aPath = outputPath
@@ -2136,8 +2255,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
} }
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { if quality != "HIGH" {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
} }
} }
@@ -2293,7 +2414,27 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Println("[Tidal] No lyrics available from parallel fetch") fmt.Println("[Tidal] No lyrics available from parallel fetch")
} }
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) { } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
}
}
}
} else {
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
} }
if !isSafOutput { if !isSafOutput {
@@ -2302,6 +2443,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
bitDepth := downloadInfo.BitDepth bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate sampleRate := downloadInfo.SampleRate
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
}
lyricsLRC := "" lyricsLRC := ""
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC lyricsLRC = parallelResult.LyricsLRC
+33
View File
@@ -3,6 +3,8 @@ package gobackend
import ( import (
"strings" "strings"
"unicode" "unicode"
"golang.org/x/text/unicode/norm"
) )
// normalizeLooseTitle collapses separators/punctuation so titles like // normalizeLooseTitle collapses separators/punctuation so titles like
@@ -33,6 +35,37 @@ func normalizeLooseTitle(title string) string {
return strings.Join(strings.Fields(b.String()), " ") return strings.Join(strings.Fields(b.String()), " ")
} }
// normalizeLooseArtistName folds diacritics and common separators so artist
// verification is resilient to variants like "Özkent" vs "Ozkent".
func normalizeLooseArtistName(name string) string {
trimmed := strings.TrimSpace(strings.ToLower(name))
if trimmed == "" {
return ""
}
decomposed := norm.NFD.String(trimmed)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
switch {
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue
case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop remaining punctuation/symbols for loose artist matching.
}
}
return strings.Join(strings.Fields(b.String()), " ")
}
func hasAlphaNumericRunes(value string) bool { func hasAlphaNumericRunes(value string) bool {
for _, r := range value { for _, r := range value {
if unicode.IsLetter(r) || unicode.IsNumber(r) { if unicode.IsLetter(r) || unicode.IsNumber(r) {
+20
View File
@@ -367,6 +367,26 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "searchTidalAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "searchQobuzAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
let trackLimit = args["track_limit"] as? Int ?? 15
let artistLimit = args["artist_limit"] as? Int ?? 3
let filter = args["filter"] as? String ?? ""
let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
if let error = error { throw error }
return response
case "getDeezerRelatedArtists": case "getDeezerRelatedArtists":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let artistId = args["artist_id"] as! String let artistId = args["artist_id"] as! String
+8 -8
View File
@@ -3,24 +3,24 @@ import 'package:flutter/foundation.dart';
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.8.8'; static const String version = '3.9.0';
static const String buildNumber = '114'; static const String buildNumber = '115';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// Shows "Internal" in debug builds, actual version in release.
static String get displayVersion => kDebugMode ? 'Internal' : version; static String get displayVersion => kDebugMode ? 'Internal' : version;
static const String appName = 'SpotiFLAC'; static const String appName = 'SpotiFLAC';
static const String copyright = '© 2026 SpotiFLAC'; static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet'; static const String mobileAuthor = 'zarzet';
static const String originalAuthor = 'afkarxyz'; static const String originalAuthor = 'afkarxyz';
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile'; static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo'; static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC'; static const String originalGithubUrl =
'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet'; static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/'; static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
} }
+85 -1
View File
@@ -2323,7 +2323,7 @@ abstract class AppLocalizations {
/// Default search provider option /// Default search provider option
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Default (Deezer/Spotify)'** /// **'Default (Deezer)'**
String get extensionDefaultProvider; String get extensionDefaultProvider;
/// Subtitle for default provider /// Subtitle for default provider
@@ -2596,6 +2596,66 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'** /// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle; String get qualityHiResFlacMaxSubtitle;
/// Quality option label for Tidal lossy 320kbps
///
/// In en, this message translates to:
/// **'Lossy 320kbps'**
String get downloadLossy320;
/// Setting title to pick output format for Tidal lossy downloads
///
/// In en, this message translates to:
/// **'Lossy Format'**
String get downloadLossyFormat;
/// Title of the Tidal lossy format picker bottom sheet
///
/// In en, this message translates to:
/// **'Lossy 320kbps Format'**
String get downloadLossy320Format;
/// Description in the Tidal lossy format picker
///
/// In en, this message translates to:
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
String get downloadLossy320FormatDesc;
/// Tidal lossy format option - MP3 320kbps
///
/// In en, this message translates to:
/// **'MP3 320kbps'**
String get downloadLossyMp3;
/// Subtitle for MP3 320kbps Tidal lossy option
///
/// In en, this message translates to:
/// **'Best compatibility, ~10MB per track'**
String get downloadLossyMp3Subtitle;
/// Tidal lossy format option - Opus 256kbps
///
/// In en, this message translates to:
/// **'Opus 256kbps'**
String get downloadLossyOpus256;
/// Subtitle for Opus 256kbps Tidal lossy option
///
/// In en, this message translates to:
/// **'Best quality Opus, ~8MB per track'**
String get downloadLossyOpus256Subtitle;
/// Tidal lossy format option - Opus 128kbps
///
/// In en, this message translates to:
/// **'Opus 128kbps'**
String get downloadLossyOpus128;
/// Subtitle for Opus 128kbps Tidal lossy option
///
/// In en, this message translates to:
/// **'Smallest size, ~4MB per track'**
String get downloadLossyOpus128Subtitle;
/// Note about quality availability /// Note about quality availability
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4659,6 +4719,30 @@ abstract class AppLocalizations {
/// **'Artist Name Filters'** /// **'Artist Name Filters'**
String get downloadArtistNameFilters; String get downloadArtistNameFilters;
/// Setting title for adding a playlist folder prefix before the normal organization structure
///
/// In en, this message translates to:
/// **'Create playlist source folder'**
String get downloadCreatePlaylistSourceFolder;
/// Subtitle when playlist source folder prefix is enabled
///
/// In en, this message translates to:
/// **'Playlist downloads use Playlist/ plus your normal folder structure.'**
String get downloadCreatePlaylistSourceFolderEnabled;
/// Subtitle when playlist source folder prefix is disabled
///
/// In en, this message translates to:
/// **'Playlist downloads use the normal folder structure only.'**
String get downloadCreatePlaylistSourceFolderDisabled;
/// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist
///
/// In en, this message translates to:
/// **'By Playlist already places downloads inside a playlist folder.'**
String get downloadCreatePlaylistSourceFolderRedundant;
/// Setting title for SongLink country region /// Setting title for SongLink country region
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+107 -54
View File
@@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; 'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
@override @override
String get artistAlbums => 'Alben'; String get artistAlbums => 'Alben';
@@ -441,7 +441,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get setupDownloadLocationIosMessage => String get setupDownloadLocationIosMessage =>
'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.'; 'Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
@override @override
String get setupAppDocumentsFolder => 'App-Dokumentenordner'; String get setupAppDocumentsFolder => 'App-Dokumentenordner';
@@ -705,15 +705,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get errorNoTracksFound => 'Keine Titel gefunden'; String get errorNoTracksFound => 'Keine Titel gefunden';
@override @override
String get errorUrlNotRecognized => 'Link not recognized'; String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
@override @override
String get errorUrlNotRecognizedMessage => String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; 'Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.';
@override @override
String get errorUrlFetchFailed => String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.'; 'Laden fehlgeschlagen. Bitte erneut versuchen.';
@override @override
String errorMissingExtensionSource(String item) { String errorMissingExtensionSource(String item) {
@@ -750,7 +750,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get selectionAllSelected => 'Alle Titel sind ausgewählt'; String get selectionAllSelected => 'Alle Titel sind ausgewählt';
@override @override
String get selectionSelectToDelete => 'Titel zum Löschen auswählen'; String get selectionSelectToDelete => 'Titel zum Löschen wählen';
@override @override
String progressFetchingMetadata(int current, int total) { String progressFetchingMetadata(int current, int total) {
@@ -767,7 +767,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get searchArtists => 'Künstler'; String get searchArtists => 'Künstler';
@override @override
String get searchAlbums => 'Albums'; String get searchAlbums => 'Alben';
@override @override
String get searchPlaylists => 'Playlisten'; String get searchPlaylists => 'Playlisten';
@@ -789,11 +789,11 @@ class AppLocalizationsDe extends AppLocalizations {
String get folderOrganizationNone => 'Keine Organisation'; String get folderOrganizationNone => 'Keine Organisation';
@override @override
String get folderOrganizationByPlaylist => 'By Playlist'; String get folderOrganizationByPlaylist => 'Nach Playlist';
@override @override
String get folderOrganizationByPlaylistSubtitle => String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist'; 'Ordner für jede Playlist trennen';
@override @override
String get folderOrganizationByArtist => 'Nach Künstler'; String get folderOrganizationByArtist => 'Nach Künstler';
@@ -810,7 +810,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get folderOrganizationNoneSubtitle => String get folderOrganizationNoneSubtitle =>
'Alle Dateien im Download-Verzeichnis'; 'Alle Dateien im Download-Ordner';
@override @override
String get folderOrganizationByArtistSubtitle => String get folderOrganizationByArtistSubtitle =>
@@ -1413,6 +1413,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
@@ -1431,19 +1463,20 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadAskBeforeDownload => 'Qualität vor Download fragen'; String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
@override @override
String get downloadDirectory => 'Downloadverzeichnis'; String get downloadDirectory => 'Download-Ordner';
@override @override
String get downloadSeparateSinglesFolder => 'Singles Ordner trennen'; String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
@override @override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; String get downloadUseAlbumArtistForFolders =>
'Album-Künstler für Ordner verwenden';
@override @override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; String get downloadUsePrimaryArtistOnly => 'Primärer Künstler nur für Ordner';
@override @override
String get downloadUsePrimaryArtistOnlyEnabled => String get downloadUsePrimaryArtistOnlyEnabled =>
@@ -1451,7 +1484,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadUsePrimaryArtistOnlyDisabled => String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name'; 'Vollständiger Künstler für Ordnername';
@override @override
String get downloadSelectQuality => 'Qualität wählen'; String get downloadSelectQuality => 'Qualität wählen';
@@ -1473,7 +1506,8 @@ class AppLocalizationsDe extends AppLocalizations {
'Bist du dir sicher, dass du alle Downloads löschen möchten?'; 'Bist du dir sicher, dass du alle Downloads löschen möchten?';
@override @override
String get settingsAutoExportFailed => 'Auto-export failed downloads'; String get settingsAutoExportFailed =>
'Auto-Export fehlgeschlagener Downloads';
@override @override
String get settingsAutoExportFailedSubtitle => String get settingsAutoExportFailedSubtitle =>
@@ -1496,14 +1530,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderArtistAlbum => 'Künstler/Album'; String get albumFolderArtistAlbum => 'Künstler/Album';
@override @override
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/'; String get albumFolderArtistAlbumSubtitle => 'Alben/Künster Name/Album Name/';
@override @override
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album'; String get albumFolderArtistYearAlbum => 'Künstler / [Year] Album';
@override @override
String get albumFolderArtistYearAlbumSubtitle => String get albumFolderArtistYearAlbumSubtitle =>
'Albums/Künster Name/[2005] Album Name/'; 'Alben/Künster Name/[2005] Album Name/';
@override @override
String get albumFolderAlbumOnly => 'Nur Alben'; String get albumFolderAlbumOnly => 'Nur Alben';
@@ -1515,14 +1549,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderYearAlbum => '[Year] Album'; String get albumFolderYearAlbum => '[Year] Album';
@override @override
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/'; String get albumFolderYearAlbumSubtitle => 'Alben/[2005] Album Name/';
@override @override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; String get albumFolderArtistAlbumSingles => 'Künstler / Album + Singles';
@override @override
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Künstler/Album/ und Künstler/Singles/';
@override @override
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen'; String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
@@ -1561,7 +1595,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete'; String get downloadedAlbumSelectToDelete => 'Titel zum Löschen wählen';
@override @override
String downloadedAlbumDiscHeader(int discNumber) { String downloadedAlbumDiscHeader(int discNumber) {
@@ -1607,7 +1641,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String discographyAlbumsOnlySubtitle(int count, int albumCount) { String discographyAlbumsOnlySubtitle(int count, int albumCount) {
return '$count Titel von $albumCount Albums'; return '$count Titel aus $albumCount Alben';
} }
@override @override
@@ -1623,14 +1657,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get discographySelectAlbumsSubtitle => String get discographySelectAlbumsSubtitle =>
'Choose specific albums or singles'; 'Wähle bestimmte Alben oder Singles';
@override @override
String get discographyFetchingTracks => 'Lade Titel...'; String get discographyFetchingTracks => 'Lade Titel...';
@override @override
String discographyFetchingAlbum(int current, int total) { String discographyFetchingAlbum(int current, int total) {
return 'Fetching $current of $total...'; return 'Lade $current von $total...';
} }
@override @override
@@ -1643,7 +1677,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String discographyAddedToQueue(int count) { String discographyAddedToQueue(int count) {
return 'Added $count tracks to queue'; return '$count Titel zur Warteschlange hinzugefügt';
} }
@override @override
@@ -1655,7 +1689,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get discographyNoAlbums => 'Es sind keine Alben verfügbar'; String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
@override @override
String get discographyFailedToFetch => 'Failed to fetch some albums'; String get discographyFailedToFetch => 'Fehler beim Abrufen einiger Alben';
@override @override
String get sectionStorageAccess => 'Speicherzugriff'; String get sectionStorageAccess => 'Speicherzugriff';
@@ -1664,14 +1698,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get allFilesAccess => 'Zugriff auf alle Dateien'; String get allFilesAccess => 'Zugriff auf alle Dateien';
@override @override
String get allFilesAccessEnabledSubtitle => 'Can write to any folder'; String get allFilesAccessEnabledSubtitle => 'Darf in jeden Ordner schreiben';
@override @override
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only'; String get allFilesAccessDisabledSubtitle => 'Nur auf Medienordner begrenzt';
@override @override
String get allFilesAccessDescription => String get allFilesAccessDescription =>
'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.'; 'Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).';
@override @override
String get allFilesAccessDeniedMessage => String get allFilesAccessDeniedMessage =>
@@ -1685,13 +1719,15 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsLocalLibrary => 'Lokale Bibliothek'; String get settingsLocalLibrary => 'Lokale Bibliothek';
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle =>
'Musik scannen & Duplikate erkennen';
@override @override
String get settingsCache => 'Speicher & Cache'; String get settingsCache => 'Speicher & Cache';
@override @override
String get settingsCacheSubtitle => 'View size and clear cached data'; String get settingsCacheSubtitle =>
'Größe anzeigen und Daten im Cache leeren';
@override @override
String get libraryTitle => 'Lokale Bibliothek'; String get libraryTitle => 'Lokale Bibliothek';
@@ -1704,7 +1740,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryEnableLocalLibrarySubtitle => String get libraryEnableLocalLibrarySubtitle =>
'Scan and track your existing music'; 'Scan und verfolge deine bestehende Musik';
@override @override
String get libraryFolder => 'Bibliotheksordner'; String get libraryFolder => 'Bibliotheksordner';
@@ -1713,7 +1749,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get libraryFolderHint => 'Tippe um Ordner auszuwählen'; String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
@override @override
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; String get libraryShowDuplicateIndicator => 'Duplikat Indikator anzeigen';
@override @override
String get libraryShowDuplicateIndicatorSubtitle => String get libraryShowDuplicateIndicatorSubtitle =>
@@ -1914,7 +1950,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik'; 'Hole dir FLAC Audio von Tidal, Qobuz oder Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -1981,7 +2017,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get tutorialSettingsTip1 => String get tutorialSettingsTip1 =>
'Downloadverzeichnis und Ordnerorganisation ändern'; 'Download-Ordner und Ordner-Organisation ändern';
@override @override
String get tutorialSettingsTip2 => String get tutorialSettingsTip2 =>
@@ -2039,14 +2075,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get cacheSectionMaintenance => 'Wartung'; String get cacheSectionMaintenance => 'Wartung';
@override @override
String get cacheAppDirectory => 'App-Cache Verzeichnis'; String get cacheAppDirectory => 'App-Cache Ordner';
@override @override
String get cacheAppDirectoryDesc => String get cacheAppDirectoryDesc =>
'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.'; 'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
@override @override
String get cacheTempDirectory => 'Temporäres Verzeichnis'; String get cacheTempDirectory => 'Temporärer Ordner';
@override @override
String get cacheTempDirectoryDesc => String get cacheTempDirectoryDesc =>
@@ -2167,11 +2203,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String trackCoverSaved(String fileName) { String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName'; return 'Cover in $fileName gespeichert';
} }
@override @override
String get trackCoverNoSource => 'No cover art source available'; String get trackCoverNoSource => 'Keine Cover Quelle vorhanden';
@override @override
String trackLyricsSaved(String fileName) { String trackLyricsSaved(String fileName) {
@@ -2269,10 +2305,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackConvertFailed => 'Konvertierung fehlgeschlagen'; String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
@override @override
String get cueSplitTitle => 'Split CUE Sheet'; String get cueSplitTitle => 'CUE-Sheet aufteilen';
@override @override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; String get cueSplitSubtitle => 'CUE+FLAC in einzelne Titel aufteilen';
@override @override
String cueSplitAlbum(String album) { String cueSplitAlbum(String album) {
@@ -2281,40 +2317,41 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String cueSplitArtist(String artist) { String cueSplitArtist(String artist) {
return 'Artist: $artist'; return 'Künstler: $artist';
} }
@override @override
String cueSplitTrackCount(int count) { String cueSplitTrackCount(int count) {
return '$count tracks'; return '$count Titel';
} }
@override @override
String get cueSplitConfirmTitle => 'Split CUE Album'; String get cueSplitConfirmTitle => 'CUE-Album aufteilen';
@override @override
String cueSplitConfirmMessage(String album, int count) { String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; return 'Soll „$album in $count einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.';
} }
@override @override
String cueSplitSplitting(int current, int total) { String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)'; return 'CUE-Sheet wird geteilt... ($current/$total)';
} }
@override @override
String cueSplitSuccess(int count) { String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully'; return '$count Titel erfolgreich aufgeteilt';
} }
@override @override
String get cueSplitFailed => 'CUE split failed'; String get cueSplitFailed => 'CUE-Aufteilung fehlgeschlagen';
@override @override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; String get cueSplitNoAudioFile =>
'Audiodatei für dieses CUE-Sheet nicht gefunden';
@override @override
String get cueSplitButton => 'Split into Tracks'; String get cueSplitButton => 'In Titel aufteilen';
@override @override
String get actionCreate => 'Erstellen'; String get actionCreate => 'Erstellen';
@@ -2539,11 +2576,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle => String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar'; 'Interpret-Ordner verwenden Album-Interpret, sofern vorhanden';
@override @override
String get downloadUseAlbumArtistForFoldersTrackSubtitle => String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only'; 'Künstler-Ordner nur für Titel-Künstler';
@override @override
String get lyricsProvidersTitle => 'Lyrics Providers'; String get lyricsProvidersTitle => 'Lyrics Providers';
@@ -2712,6 +2749,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+49 -1
View File
@@ -1240,7 +1240,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found'; String get storeEmptyNoResults => 'No extensions found';
@override @override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; String get extensionDefaultProvider => 'Default (Deezer)';
@override @override
String get extensionDefaultProviderSubtitle => 'Use built-in search'; String get extensionDefaultProviderSubtitle => 'Use built-in search';
@@ -1389,6 +1389,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -2685,6 +2717,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+344 -2
View File
@@ -1389,6 +1389,38 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -2685,6 +2717,22 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
@@ -3278,7 +3326,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Álbumes'; String get artistAlbums => 'Álbumes';
@@ -3613,6 +3661,17 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get errorNoTracksFound => 'No se encontraron pistas'; String get errorNoTracksFound => 'No se encontraron pistas';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override @override
String errorMissingExtensionSource(String item) { String errorMissingExtensionSource(String item) {
return 'No se puede cargar $item: falta una fuente de extensión'; return 'No se puede cargar $item: falta una fuente de extensión';
@@ -3676,9 +3735,23 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get filenameFormat => 'Formato del nombre del archivo'; String get filenameFormat => 'Formato del nombre del archivo';
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganizationNone => 'Ninguna organización'; String get folderOrganizationNone => 'Ninguna organización';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override @override
String get folderOrganizationByArtist => 'Por Artista'; String get folderOrganizationByArtist => 'Por Artista';
@@ -4265,6 +4338,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
String get youtubeQualityNote => String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.'; 'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Preguntar antes de descargar'; String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
@@ -4597,6 +4676,17 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
String get libraryAboutDescription => String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -4723,7 +4813,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -5040,6 +5130,258 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override @override
String get trackConvertFailed => 'Conversion failed'; String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override @override
String downloadedAlbumDownloadedCount(int count) { String downloadedAlbumDownloadedCount(int count) {
return '$count descargado'; return '$count descargado';
+50 -2
View File
@@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@@ -1391,6 +1391,38 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1892,7 +1924,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -2686,6 +2718,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+50 -2
View File
@@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@@ -1389,6 +1389,38 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1890,7 +1922,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -2684,6 +2716,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+105 -55
View File
@@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; 'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
@override @override
String get artistAlbums => 'Album'; String get artistAlbums => 'Album';
@@ -769,21 +769,21 @@ class AppLocalizationsId extends AppLocalizations {
String get filenameFormat => 'Format Nama File'; String get filenameFormat => 'Format Nama File';
@override @override
String get filenameShowAdvancedTags => 'Show advanced tags'; String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
@override @override
String get filenameShowAdvancedTagsDescription => String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns'; 'Aktifkan tag yang diformat untuk padding trek dan pola tanggal';
@override @override
String get folderOrganizationNone => 'Tidak ada'; String get folderOrganizationNone => 'Tidak ada';
@override @override
String get folderOrganizationByPlaylist => 'By Playlist'; String get folderOrganizationByPlaylist => 'Berdasarkan Daftar Putar';
@override @override
String get folderOrganizationByPlaylistSubtitle => String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist'; 'Setiap daftar putar memerlukan folder terpisah';
@override @override
String get folderOrganizationByArtist => 'Berdasarkan Artis'; String get folderOrganizationByArtist => 'Berdasarkan Artis';
@@ -939,13 +939,13 @@ class AppLocalizationsId extends AppLocalizations {
'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.'; 'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.';
@override @override
String get credentialsClientId => 'Client ID'; String get credentialsClientId => 'ID Klien';
@override @override
String get credentialsClientIdHint => 'Tempel Client ID'; String get credentialsClientIdHint => 'Tempel Client ID';
@override @override
String get credentialsClientSecret => 'Client Secret'; String get credentialsClientSecret => 'Rahasia Klien';
@override @override
String get credentialsClientSecretHint => 'Tempel Client Secret'; String get credentialsClientSecretHint => 'Tempel Client Secret';
@@ -954,7 +954,7 @@ class AppLocalizationsId extends AppLocalizations {
String get channelStable => 'Stabil'; String get channelStable => 'Stabil';
@override @override
String get channelPreview => 'Preview'; String get channelPreview => 'Pratinjau';
@override @override
String get sectionSearchSource => 'Sumber Pencarian'; String get sectionSearchSource => 'Sumber Pencarian';
@@ -984,33 +984,34 @@ class AppLocalizationsId extends AppLocalizations {
String get sectionFileSettings => 'Pengaturan File'; String get sectionFileSettings => 'Pengaturan File';
@override @override
String get sectionLyrics => 'Lyrics'; String get sectionLyrics => 'Lirik';
@override @override
String get lyricsMode => 'Lyrics Mode'; String get lyricsMode => 'Mode Lirik';
@override @override
String get lyricsModeDescription => String get lyricsModeDescription =>
'Choose how lyrics are saved with your downloads'; 'Pilih cara lirik disimpan bersama unduhan Anda';
@override @override
String get lyricsModeEmbed => 'Embed in file'; String get lyricsModeEmbed => 'Sematkan dalam file';
@override @override
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata'; String get lyricsModeEmbedSubtitle =>
'Lirik tersimpan di dalam metadata FLAC';
@override @override
String get lyricsModeExternal => 'External .lrc file'; String get lyricsModeExternal => 'File .lrc eksternal';
@override @override
String get lyricsModeExternalSubtitle => String get lyricsModeExternalSubtitle =>
'Separate .lrc file for players like Samsung Music'; 'File .lrc terpisah untuk pemutar musik seperti Samsung Music';
@override @override
String get lyricsModeBoth => 'Both'; String get lyricsModeBoth => 'Keduanya';
@override @override
String get lyricsModeBothSubtitle => 'Embed and save .lrc file'; String get lyricsModeBothSubtitle => 'Sematkan dan simpan file .lrc';
@override @override
String get sectionColor => 'Warna'; String get sectionColor => 'Warna';
@@ -1122,10 +1123,10 @@ class AppLocalizationsId extends AppLocalizations {
String get trackGenre => 'Genre'; String get trackGenre => 'Genre';
@override @override
String get trackLabel => 'Label'; String get trackLabel => 'Lebel';
@override @override
String get trackCopyright => 'Copyright'; String get trackCopyright => 'Hak cipta';
@override @override
String get trackDownloaded => 'Diunduh'; String get trackDownloaded => 'Diunduh';
@@ -1143,13 +1144,13 @@ class AppLocalizationsId extends AppLocalizations {
String get trackLyricsLoadFailed => 'Gagal memuat lirik'; String get trackLyricsLoadFailed => 'Gagal memuat lirik';
@override @override
String get trackEmbedLyrics => 'Embed Lyrics'; String get trackEmbedLyrics => 'Sematkan Lirik';
@override @override
String get trackLyricsEmbedded => 'Lyrics embedded successfully'; String get trackLyricsEmbedded => 'Lirik berhasil disematkan';
@override @override
String get trackInstrumental => 'Instrumental track'; String get trackInstrumental => 'Lagu instrumental';
@override @override
String get trackCopiedToClipboard => 'Disalin ke clipboard'; String get trackCopiedToClipboard => 'Disalin ke clipboard';
@@ -1245,7 +1246,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found'; String get storeEmptyNoResults => 'No extensions found';
@override @override
String get extensionDefaultProvider => 'Default (Deezer/Spotify)'; String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
@override @override
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan'; String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
@@ -1257,7 +1258,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionId => 'ID'; String get extensionId => 'ID';
@override @override
String get extensionError => 'Error'; String get extensionError => 'Terjadi kesalahan';
@override @override
String get extensionCapabilities => 'Kemampuan'; String get extensionCapabilities => 'Kemampuan';
@@ -1396,19 +1397,51 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@override @override
String get youtubeQualityNote => String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.'; 'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
@override @override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
@override @override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
@override @override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1423,18 +1456,19 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadAlbumFolderStructure => 'Struktur Folder Album'; String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override @override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders'; String get downloadUseAlbumArtistForFolders =>
'Gunakan Artis Album untuk folder';
@override @override
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders'; String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
@override @override
String get downloadUsePrimaryArtistOnlyEnabled => String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'; 'Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)';
@override @override
String get downloadUsePrimaryArtistOnlyDisabled => String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name'; 'Nama lengkap artis digunakan untuk nama folder';
@override @override
String get downloadSelectQuality => 'Pilih Kualitas'; String get downloadSelectQuality => 'Pilih Kualitas';
@@ -1456,24 +1490,24 @@ class AppLocalizationsId extends AppLocalizations {
'Apakah Anda yakin ingin menghapus semua unduhan?'; 'Apakah Anda yakin ingin menghapus semua unduhan?';
@override @override
String get settingsAutoExportFailed => 'Auto-export failed downloads'; String get settingsAutoExportFailed => 'Unduhan yang gagal diekspor otomatis';
@override @override
String get settingsAutoExportFailedSubtitle => String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically'; 'Simpan unduhan yang gagal ke file TXT secara otomatis';
@override @override
String get settingsDownloadNetwork => 'Download Network'; String get settingsDownloadNetwork => 'Jaringan Unduhan';
@override @override
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data'; String get settingsDownloadNetworkAny => 'WiFi + Data Seluler';
@override @override
String get settingsDownloadNetworkWifiOnly => 'WiFi Only'; String get settingsDownloadNetworkWifiOnly => 'Hanya WiFi';
@override @override
String get settingsDownloadNetworkSubtitle => String get settingsDownloadNetworkSubtitle =>
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.'; 'Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.';
@override @override
String get albumFolderArtistAlbum => 'Artis / Album'; String get albumFolderArtistAlbum => 'Artis / Album';
@@ -1501,11 +1535,11 @@ class AppLocalizationsId extends AppLocalizations {
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/'; String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
@override @override
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles'; String get albumFolderArtistAlbumSingles => 'Artis / Album + Singel';
@override @override
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artis/Album/ dan Artis/Single/';
@override @override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -1561,21 +1595,21 @@ class AppLocalizationsId extends AppLocalizations {
String get recentTypeSong => 'Lagu'; String get recentTypeSong => 'Lagu';
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Daftar putar';
@override @override
String get recentEmpty => 'No recent items yet'; String get recentEmpty => 'Belum ada item terbaru';
@override @override
String get recentShowAllDownloads => 'Show All Downloads'; String get recentShowAllDownloads => 'Tampilkan Semua Unduhan';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Daftar Putar: $name';
} }
@override @override
String get discographyDownload => 'Download Discography'; String get discographyDownload => 'Unduh Diskografi';
@override @override
String get discographyDownloadAll => 'Unduh Semua'; String get discographyDownloadAll => 'Unduh Semua';
@@ -1885,44 +1919,44 @@ class AppLocalizationsId extends AppLocalizations {
} }
@override @override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!'; String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
@override @override
String get tutorialWelcomeDesc => String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'; 'Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
@override @override
String get tutorialWelcomeTip1 => String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL'; 'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding'; 'Penyematan metadata, sampul album, dan lirik secara otomatis';
@override @override
String get tutorialSearchTitle => 'Finding Music'; String get tutorialSearchTitle => 'Menemukan Musik';
@override @override
String get tutorialSearchDesc => String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.'; 'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
@override @override
String get tutorialDownloadTitle => 'Downloading Music'; String get tutorialDownloadTitle => 'Mengunduh Musik';
@override @override
String get tutorialDownloadDesc => String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.'; 'Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.';
@override @override
String get tutorialLibraryTitle => 'Your Library'; String get tutorialLibraryTitle => 'Perpustakaan Anda';
@override @override
String get tutorialLibraryDesc => String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.'; 'Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.';
@override @override
String get tutorialLibraryTip1 => String get tutorialLibraryTip1 =>
@@ -2692,6 +2726,22 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Buat folder sumber playlist';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Unduhan dari playlist hanya memakai struktur folder normal.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+62 -14
View File
@@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'アルバム'; String get artistAlbums => 'アルバム';
@@ -761,7 +761,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get filenameFormat => 'ファイル名の形式'; String get filenameFormat => 'ファイル名の形式';
@override @override
String get filenameShowAdvancedTags => 'Show advanced tags'; String get filenameShowAdvancedTags => '高度なタグを表示';
@override @override
String get filenameShowAdvancedTagsDescription => String get filenameShowAdvancedTagsDescription =>
@@ -1138,7 +1138,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackLyricsEmbedded => 'Lyrics embedded successfully'; String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override @override
String get trackInstrumental => 'Instrumental track'; String get trackInstrumental => 'インストゥルメンタルのトラック';
@override @override
String get trackCopiedToClipboard => 'クリップボードにコピーしました'; String get trackCopiedToClipboard => 'クリップボードにコピーしました';
@@ -1379,6 +1379,38 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@@ -1877,7 +1909,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -2229,7 +2261,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackConvertFailed => '変換に失敗しました'; String get trackConvertFailed => '変換に失敗しました';
@override @override
String get cueSplitTitle => 'Split CUE Sheet'; String get cueSplitTitle => '分割 CUE シート';
@override @override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@@ -2379,7 +2411,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get collectionRemoveFromPlaylist => 'Remove from playlist'; String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override @override
String get collectionRemoveFromFolder => 'Remove from folder'; String get collectionRemoveFromFolder => 'フォルダから削除';
@override @override
String collectionRemoved(String trackName) { String collectionRemoved(String trackName) {
@@ -2413,26 +2445,26 @@ class AppLocalizationsJa extends AppLocalizations {
String get trackOptionRemoveFromLoved => 'Remove from Loved'; String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override @override
String get trackOptionAddToWishlist => 'Add to Wishlist'; String get trackOptionAddToWishlist => 'ウィッシュリストに追加';
@override @override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; String get trackOptionRemoveFromWishlist => 'ウィッシュから削除';
@override @override
String get collectionPlaylistChangeCover => 'Change cover image'; String get collectionPlaylistChangeCover => 'カバー画像を変更';
@override @override
String get collectionPlaylistRemoveCover => 'Remove cover image'; String get collectionPlaylistRemoveCover => 'カバー画像を削除';
@override @override
String selectionShareCount(int count) { String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: 'tracks', other: '個のトラック',
one: 'track', one: '個のトラック',
); );
return 'Share $count $_temp0'; return '$count $_temp0を共有';
} }
@override @override
@@ -2453,7 +2485,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get selectionConvertNoConvertible => 'No convertible tracks selected'; String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override @override
String get selectionBatchConvertConfirmTitle => 'Batch Convert'; String get selectionBatchConvertConfirmTitle => '一括変換';
@override @override
String selectionBatchConvertConfirmMessage( String selectionBatchConvertConfirmMessage(
@@ -2671,6 +2703,22 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+50 -2
View File
@@ -344,7 +344,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => '앨범'; String get artistAlbums => '앨범';
@@ -1369,6 +1369,38 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1870,7 +1902,7 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -2664,6 +2696,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+54 -6
View File
@@ -158,16 +158,16 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@override @override
String get optionsConcurrentSequential => 'Sequential (1 at a time)'; String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
@override @override
String optionsConcurrentParallel(int count) { String optionsConcurrentParallel(int count) {
return '$count parallel downloads'; return '';
} }
@override @override
String get optionsConcurrentWarning => String get optionsConcurrentWarning =>
'Parallel downloads may trigger rate limiting'; 'Parallel downloaden kan leiden tot rate-limiting';
@override @override
String get optionsExtensionStore => 'Extension Store'; String get optionsExtensionStore => 'Extension Store';
@@ -271,7 +271,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutContributors => 'Contributors'; String get aboutContributors => 'Contributors';
@override @override
String get aboutMobileDeveloper => 'Mobile version developer'; String get aboutMobileDeveloper => '';
@override @override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
@@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@@ -1389,6 +1389,38 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1890,7 +1922,7 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -2684,6 +2716,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+344 -2
View File
@@ -1389,6 +1389,38 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -2685,6 +2717,22 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
@@ -3278,7 +3326,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Álbuns'; String get artistAlbums => 'Álbuns';
@@ -3612,6 +3660,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get errorNoTracksFound => 'Nenhuma faixa encontrada'; String get errorNoTracksFound => 'Nenhuma faixa encontrada';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override @override
String errorMissingExtensionSource(String item) { String errorMissingExtensionSource(String item) {
return 'Não é possível carregar $item: faltando a fonte da extensão'; return 'Não é possível carregar $item: faltando a fonte da extensão';
@@ -3675,9 +3734,23 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get filenameFormat => 'Formato do Nome do Arquivo'; String get filenameFormat => 'Formato do Nome do Arquivo';
@override
String get filenameShowAdvancedTags => 'Show advanced tags';
@override
String get filenameShowAdvancedTagsDescription =>
'Enable formatted tags for track padding and date patterns';
@override @override
String get folderOrganizationNone => 'Nenhuma organização'; String get folderOrganizationNone => 'Nenhuma organização';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override @override
String get folderOrganizationByArtist => 'Por Artista'; String get folderOrganizationByArtist => 'Por Artista';
@@ -4262,6 +4335,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get youtubeQualityNote => String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.'; 'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar'; String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
@@ -4594,6 +4673,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get libraryAboutDescription => String get libraryAboutDescription =>
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.'; 'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
@override
String libraryTracksUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -4720,7 +4810,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -5037,6 +5127,258 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override @override
String get trackConvertFailed => 'Conversion failed'; String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override
String get actionCreate => 'Create';
@override
String get collectionFoldersTitle => 'My folders';
@override
String get collectionWishlist => 'Wishlist';
@override
String get collectionLoved => 'Loved';
@override
String get collectionPlaylists => 'Playlists';
@override
String get collectionPlaylist => 'Playlist';
@override
String get collectionAddToPlaylist => 'Add to playlist';
@override
String get collectionCreatePlaylist => 'Create playlist';
@override
String get collectionNoPlaylistsYet => 'No playlists yet';
@override
String get collectionNoPlaylistsSubtitle =>
'Create a playlist to start categorizing tracks';
@override
String collectionPlaylistTracks(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String collectionAddedToPlaylist(String playlistName) {
return 'Added to \"$playlistName\"';
}
@override
String collectionAlreadyInPlaylist(String playlistName) {
return 'Already in \"$playlistName\"';
}
@override
String get collectionPlaylistCreated => 'Playlist created';
@override
String get collectionPlaylistNameHint => 'Playlist name';
@override
String get collectionPlaylistNameRequired => 'Playlist name is required';
@override
String get collectionRenamePlaylist => 'Rename playlist';
@override
String get collectionDeletePlaylist => 'Delete playlist';
@override
String collectionDeletePlaylistMessage(String playlistName) {
return 'Delete \"$playlistName\" and all tracks inside it?';
}
@override
String get collectionPlaylistDeleted => 'Playlist deleted';
@override
String get collectionPlaylistRenamed => 'Playlist renamed';
@override
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
@override
String get collectionWishlistEmptySubtitle =>
'Tap + on tracks to save what you want to download later';
@override
String get collectionLovedEmptyTitle => 'Loved folder is empty';
@override
String get collectionLovedEmptySubtitle =>
'Tap love on tracks to keep your favorites';
@override
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
@override
String get collectionPlaylistEmptySubtitle =>
'Long-press + on any track to add it here';
@override
String get collectionRemoveFromPlaylist => 'Remove from playlist';
@override
String get collectionRemoveFromFolder => 'Remove from folder';
@override
String collectionRemoved(String trackName) {
return '\"$trackName\" removed';
}
@override
String collectionAddedToLoved(String trackName) {
return '\"$trackName\" added to Loved';
}
@override
String collectionRemovedFromLoved(String trackName) {
return '\"$trackName\" removed from Loved';
}
@override
String collectionAddedToWishlist(String trackName) {
return '\"$trackName\" added to Wishlist';
}
@override
String collectionRemovedFromWishlist(String trackName) {
return '\"$trackName\" removed from Wishlist';
}
@override
String get trackOptionAddToLoved => 'Add to Loved';
@override
String get trackOptionRemoveFromLoved => 'Remove from Loved';
@override
String get trackOptionAddToWishlist => 'Add to Wishlist';
@override
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
@override
String get collectionPlaylistChangeCover => 'Change cover image';
@override
String get collectionPlaylistRemoveCover => 'Remove cover image';
@override
String selectionShareCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Share $count $_temp0';
}
@override
String get selectionShareNoFiles => 'No shareable files found';
@override
String selectionConvertCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0';
}
@override
String get selectionConvertNoConvertible => 'No convertible tracks selected';
@override
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
@override
String selectionBatchConvertConfirmMessage(
int count,
String format,
String bitrate,
) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
}
@override
String selectionBatchConvertSuccess(int success, int total, String format) {
return 'Converted $success of $total tracks to $format';
}
@override @override
String downloadedAlbumDownloadedCount(int count) { String downloadedAlbumDownloadedCount(int count) {
return '$count baixado(s)'; return '$count baixado(s)';
+75 -26
View File
@@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; 'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
@override @override
String get artistAlbums => 'Альбомы'; String get artistAlbums => 'Альбомы';
@@ -706,15 +706,15 @@ class AppLocalizationsRu extends AppLocalizations {
String get errorNoTracksFound => 'Треки не найдены'; String get errorNoTracksFound => 'Треки не найдены';
@override @override
String get errorUrlNotRecognized => 'Link not recognized'; String get errorUrlNotRecognized => 'Ссылка не распознана';
@override @override
String get errorUrlNotRecognizedMessage => String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.'; 'Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.';
@override @override
String get errorUrlFetchFailed => String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.'; 'Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.';
@override @override
String errorMissingExtensionSource(String item) { String errorMissingExtensionSource(String item) {
@@ -790,11 +790,11 @@ class AppLocalizationsRu extends AppLocalizations {
String get folderOrganizationNone => 'Без организации'; String get folderOrganizationNone => 'Без организации';
@override @override
String get folderOrganizationByPlaylist => 'By Playlist'; String get folderOrganizationByPlaylist => 'По плейлисту';
@override @override
String get folderOrganizationByPlaylistSubtitle => String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist'; 'Отдельная папка для каждого плейлиста';
@override @override
String get folderOrganizationByArtist => 'По исполнителю'; String get folderOrganizationByArtist => 'По исполнителю';
@@ -1414,6 +1414,38 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц'; String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе'; 'Фактическое качество зависит от доступности треков в сервисе';
@@ -1450,7 +1482,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadUsePrimaryArtistOnlyEnabled => String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'; 'Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)';
@override @override
String get downloadUsePrimaryArtistOnlyDisabled => String get downloadUsePrimaryArtistOnlyDisabled =>
@@ -1940,7 +1972,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Скачайте FLAC с Tidal, Qobuz или Amazon Music'; 'Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -2036,7 +2068,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String cleanupOrphanedDownloadsResult(int count) { String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history'; return 'Удалено $count утерянных записей из истории';
} }
@override @override
@@ -2061,7 +2093,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get cacheSectionStorage => 'Кэшированные данные'; String get cacheSectionStorage => 'Кэшированные данные';
@override @override
String get cacheSectionMaintenance => 'Maintenance'; String get cacheSectionMaintenance => 'Обслуживание';
@override @override
String get cacheAppDirectory => 'Папка кэша приложения'; String get cacheAppDirectory => 'Папка кэша приложения';
@@ -2107,7 +2139,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get cacheCleanupUnusedDesc => String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.'; 'Удалить записи из истории загрузок и библиотеки, которые остались без файлов.';
@override @override
String get cacheNoData => 'Нет кэшированных данных'; String get cacheNoData => 'Нет кэшированных данных';
@@ -2155,7 +2187,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get cacheCleanupUnusedSubtitle => String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries'; 'Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке';
@override @override
String cacheCleanupResult(int downloadCount, int libraryCount) { String cacheCleanupResult(int downloadCount, int libraryCount) {
@@ -2295,52 +2327,52 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackConvertFailed => 'Ошибка конвертации'; String get trackConvertFailed => 'Ошибка конвертации';
@override @override
String get cueSplitTitle => 'Split CUE Sheet'; String get cueSplitTitle => 'Разделить CUE Sheet';
@override @override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks'; String get cueSplitSubtitle => 'Разделить файл CUE+FLAC на отдельные треки';
@override @override
String cueSplitAlbum(String album) { String cueSplitAlbum(String album) {
return 'Album: $album'; return 'Альбом: $album';
} }
@override @override
String cueSplitArtist(String artist) { String cueSplitArtist(String artist) {
return 'Artist: $artist'; return 'Артист: $artist';
} }
@override @override
String cueSplitTrackCount(int count) { String cueSplitTrackCount(int count) {
return '$count tracks'; return '$count треков';
} }
@override @override
String get cueSplitConfirmTitle => 'Split CUE Album'; String get cueSplitConfirmTitle => 'Разделенный CUE-альбом';
@override @override
String cueSplitConfirmMessage(String album, int count) { String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.'; return 'Разбить \"$album\" на $count отдельных FLAC-файлов?';
} }
@override @override
String cueSplitSplitting(int current, int total) { String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)'; return 'Разделение CUE sheet... ($current/$total)';
} }
@override @override
String cueSplitSuccess(int count) { String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully'; return 'Успешно разделено на $count треков';
} }
@override @override
String get cueSplitFailed => 'CUE split failed'; String get cueSplitFailed => 'Разделение CUE не удалось';
@override @override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet'; String get cueSplitNoAudioFile => 'Аудиофайл для этого CUE sheet не найден';
@override @override
String get cueSplitButton => 'Split into Tracks'; String get cueSplitButton => 'Разделить на Треки';
@override @override
String get actionCreate => 'Создать'; String get actionCreate => 'Создать';
@@ -2506,7 +2538,8 @@ class AppLocalizationsRu extends AppLocalizations {
} }
@override @override
String get selectionShareNoFiles => 'No shareable files found'; String get selectionShareNoFiles =>
'Файлы, доступные для совместного доступа, не найдены';
@override @override
String selectionConvertCount(int count) { String selectionConvertCount(int count) {
@@ -2539,7 +2572,7 @@ class AppLocalizationsRu extends AppLocalizations {
other: 'tracks', other: 'tracks',
one: 'track', one: 'track',
); );
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.'; return 'Преобразовать $count $_temp0 в $format с $bitrate?';
} }
@override @override
@@ -2743,6 +2776,22 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+184 -142
View File
@@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Albümler'; String get artistAlbums => 'Albümler';
@@ -664,11 +664,11 @@ class AppLocalizationsTr extends AppLocalizations {
String get snackbarSelectExtFile => 'Lütfen .spotiflac-ext dosyasını seçin'; String get snackbarSelectExtFile => 'Lütfen .spotiflac-ext dosyasını seçin';
@override @override
String get snackbarProviderPrioritySaved => 'Sağlayıcı önceliği kaydedildi'; String get snackbarProviderPrioritySaved => 'Provider priority saved';
@override @override
String get snackbarMetadataProviderSaved => String get snackbarMetadataProviderSaved =>
'Metadata sağlayıcı önceliği kaydedildi'; 'Metadata provider priority saved';
@override @override
String snackbarExtensionInstalled(String extensionName) { String snackbarExtensionInstalled(String extensionName) {
@@ -869,21 +869,21 @@ class AppLocalizationsTr extends AppLocalizations {
String get providerExtension => 'Eklenti'; String get providerExtension => 'Eklenti';
@override @override
String get metadataProviderPriorityTitle => 'Metadata Önceliği'; String get metadataProviderPriorityTitle => 'Metadata Priority';
@override @override
String get metadataProviderPriorityDescription => String get metadataProviderPriorityDescription =>
'Metadata sağlayıcılarını sıralamak için kaydır. Uygulama şarkı ararken ve metadata alırken sağlayıcıları yukarıdan aşağıya doğru deneyecektir.'; 'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.';
@override @override
String get metadataProviderPriorityInfo => String get metadataProviderPriorityInfo =>
'Deezer\'ın istek sınırı yok ve birincil olarak önerilir. Spotify çok fazla istekten sonra sınırlama yapabilir.'; 'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.';
@override @override
String get metadataNoRateLimits => 'İstek sınırı yok'; String get metadataNoRateLimits => 'No rate limits';
@override @override
String get metadataMayRateLimit => 'Sınırlama yapabilir'; String get metadataMayRateLimit => 'May rate limit';
@override @override
String get logTitle => 'Kayıtlar'; String get logTitle => 'Kayıtlar';
@@ -914,14 +914,13 @@ class AppLocalizationsTr extends AppLocalizations {
'Tüm kayıtları temizlemek istediğinize emin misiniz?'; 'Tüm kayıtları temizlemek istediğinize emin misiniz?';
@override @override
String get logFilterBySeverity => 'Kayıtları önem derecesine göre filtrele'; String get logFilterBySeverity => 'Filter logs by severity';
@override @override
String get logNoLogsYet => 'Henüz kayıt yok'; String get logNoLogsYet => 'No logs yet';
@override @override
String get logNoLogsYetSubtitle => String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app';
'Uygulamayı kullandıkça kayıtlar burada görünecek';
@override @override
String logEntriesFiltered(int count) { String logEntriesFiltered(int count) {
@@ -934,128 +933,125 @@ class AppLocalizationsTr extends AppLocalizations {
} }
@override @override
String get credentialsTitle => 'Spotify Kimlik Bilgileri'; String get credentialsTitle => 'Spotify Credentials';
@override @override
String get credentialsDescription => String get credentialsDescription =>
'Kendi Spotify uygulama kotanızı kullanmak için Client ID ve Secret girin.'; 'Enter your Client ID and Secret to use your own Spotify application quota.';
@override @override
String get credentialsClientId => 'Client ID'; String get credentialsClientId => 'Client ID';
@override @override
String get credentialsClientIdHint => 'Client ID yapıştır'; String get credentialsClientIdHint => 'Paste Client ID';
@override @override
String get credentialsClientSecret => 'Client Secret'; String get credentialsClientSecret => 'Client Secret';
@override @override
String get credentialsClientSecretHint => 'Client Secret yapıştır'; String get credentialsClientSecretHint => 'Paste Client Secret';
@override @override
String get channelStable => 'Kararlı'; String get channelStable => 'Stable';
@override @override
String get channelPreview => 'Önizleme'; String get channelPreview => 'Preview';
@override @override
String get sectionSearchSource => 'Arama Kaynağı'; String get sectionSearchSource => 'Search Source';
@override @override
String get sectionDownload => 'İndirme'; String get sectionDownload => 'Download';
@override @override
String get sectionPerformance => 'Performans'; String get sectionPerformance => 'Performance';
@override @override
String get sectionApp => 'Uygulama'; String get sectionApp => 'App';
@override @override
String get sectionData => 'Veri'; String get sectionData => 'Data';
@override @override
String get sectionDebug => 'Hata Ayıklama'; String get sectionDebug => 'Debug';
@override @override
String get sectionService => 'Hizmet'; String get sectionService => 'Service';
@override @override
String get sectionAudioQuality => 'Ses Kalitesi'; String get sectionAudioQuality => 'Audio Quality';
@override @override
String get sectionFileSettings => 'Dosya Ayarları'; String get sectionFileSettings => 'File Settings';
@override @override
String get sectionLyrics => 'Şarkı Sözleri'; String get sectionLyrics => 'Lyrics';
@override @override
String get lyricsMode => 'Şarkı Sözü Modu'; String get lyricsMode => 'Lyrics Mode';
@override @override
String get lyricsModeDescription => String get lyricsModeDescription =>
'Şarkı sözlerinin indirmelerle nasıl kaydedileceğini seçin'; 'Choose how lyrics are saved with your downloads';
@override @override
String get lyricsModeEmbed => 'Dosyaya göm'; String get lyricsModeEmbed => 'Embed in file';
@override @override
String get lyricsModeEmbedSubtitle => String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
'Şarkı sözleri FLAC metadata içinde saklanır';
@override @override
String get lyricsModeExternal => 'Harici .lrc dosyası'; String get lyricsModeExternal => 'External .lrc file';
@override @override
String get lyricsModeExternalSubtitle => String get lyricsModeExternalSubtitle =>
'Samsung Music gibi oynatıcılar için ayrı .lrc dosyası'; 'Separate .lrc file for players like Samsung Music';
@override @override
String get lyricsModeBoth => 'Her ikisi'; String get lyricsModeBoth => 'Both';
@override @override
String get lyricsModeBothSubtitle => 'Göm ve .lrc dosyası kaydet'; String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
@override @override
String get sectionColor => 'Renk'; String get sectionColor => 'Color';
@override @override
String get sectionTheme => 'Tema'; String get sectionTheme => 'Theme';
@override @override
String get sectionLayout => 'Düzen'; String get sectionLayout => 'Layout';
@override @override
String get sectionLanguage => 'Dil'; String get sectionLanguage => 'Language';
@override @override
String get appearanceLanguage => 'Uygulama Dili'; String get appearanceLanguage => 'App Language';
@override @override
String get settingsAppearanceSubtitle => 'Tema, renkler, görünüm'; String get settingsAppearanceSubtitle => 'Theme, colors, display';
@override @override
String get settingsDownloadSubtitle => 'Hizmet, kalite, dosya adı formatı'; String get settingsDownloadSubtitle => 'Service, quality, filename format';
@override @override
String get settingsOptionsSubtitle => String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
'Yedek, şarkı sözleri, kapak resmi, güncellemeler';
@override @override
String get settingsExtensionsSubtitle => 'İndirme sağlayıcılarını yönet'; String get settingsExtensionsSubtitle => 'Manage download providers';
@override @override
String get settingsLogsSubtitle => String get settingsLogsSubtitle => 'View app logs for debugging';
'Hata ayıklama için uygulama kayıtlarını görüntüle';
@override @override
String get loadingSharedLink => 'Paylaşılan bağlantı yükleniyor...'; String get loadingSharedLink => 'Loading shared link...';
@override @override
String get pressBackAgainToExit => 'Çıkmak için tekrar geri basın'; String get pressBackAgainToExit => 'Press back again to exit';
@override @override
String downloadAllCount(int count) { String downloadAllCount(int count) {
return 'Tümünü İndir ($count)'; return 'Download All ($count)';
} }
@override @override
@@ -1063,151 +1059,150 @@ class AppLocalizationsTr extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
count, count,
locale: localeName, locale: localeName,
other: '$count şarkı', other: '$count tracks',
one: '1 şarkı', one: '1 track',
); );
return '$_temp0'; return '$_temp0';
} }
@override @override
String get trackCopyFilePath => 'Dosya yolunu kopyala'; String get trackCopyFilePath => 'Copy file path';
@override @override
String get trackRemoveFromDevice => 'Cihazdan kaldır'; String get trackRemoveFromDevice => 'Remove from device';
@override @override
String get trackLoadLyrics => 'Şarkı Sözlerini Yükle'; String get trackLoadLyrics => 'Load Lyrics';
@override @override
String get trackMetadata => 'Metadata'; String get trackMetadata => 'Metadata';
@override @override
String get trackFileInfo => 'Dosya Bilgisi'; String get trackFileInfo => 'File Info';
@override @override
String get trackLyrics => 'Şarkı Sözleri'; String get trackLyrics => 'Lyrics';
@override @override
String get trackFileNotFound => 'Dosya bulunamadı'; String get trackFileNotFound => 'File not found';
@override @override
String get trackOpenInDeezer => 'Deezer\'da aç'; String get trackOpenInDeezer => 'Open in Deezer';
@override @override
String get trackOpenInSpotify => 'Spotify\'da aç'; String get trackOpenInSpotify => 'Open in Spotify';
@override @override
String get trackTrackName => 'Şarkı adı'; String get trackTrackName => 'Track name';
@override @override
String get trackArtist => 'Sanatçı'; String get trackArtist => 'Artist';
@override @override
String get trackAlbumArtist => 'Albüm sanatçısı'; String get trackAlbumArtist => 'Album artist';
@override @override
String get trackAlbum => 'Albüm'; String get trackAlbum => 'Album';
@override @override
String get trackTrackNumber => 'Şarkı numarası'; String get trackTrackNumber => 'Track number';
@override @override
String get trackDiscNumber => 'Disk numarası'; String get trackDiscNumber => 'Disc number';
@override @override
String get trackDuration => 'Süre'; String get trackDuration => 'Duration';
@override @override
String get trackAudioQuality => 'Ses kalitesi'; String get trackAudioQuality => 'Audio quality';
@override @override
String get trackReleaseDate => 'Yayın tarihi'; String get trackReleaseDate => 'Release date';
@override @override
String get trackGenre => 'Tür'; String get trackGenre => 'Genre';
@override @override
String get trackLabel => 'Plak şirketi'; String get trackLabel => 'Label';
@override @override
String get trackCopyright => 'Telif hakkı'; String get trackCopyright => 'Copyright';
@override @override
String get trackDownloaded => 'İndirildi'; String get trackDownloaded => 'Downloaded';
@override @override
String get trackCopyLyrics => 'Şarkı sözlerini kopyala'; String get trackCopyLyrics => 'Copy lyrics';
@override @override
String get trackLyricsNotAvailable => 'Bu şarkı için şarkı sözü mevcut değil'; String get trackLyricsNotAvailable => 'Lyrics not available for this track';
@override @override
String get trackLyricsTimeout => String get trackLyricsTimeout => 'Request timed out. Try again later.';
'İstek zaman aşımına uğradı. Daha sonra tekrar deneyin.';
@override @override
String get trackLyricsLoadFailed => 'Şarkı sözleri yüklenemedi'; String get trackLyricsLoadFailed => 'Failed to load lyrics';
@override @override
String get trackEmbedLyrics => 'Şarkı Sözlerini Göm'; String get trackEmbedLyrics => 'Embed Lyrics';
@override @override
String get trackLyricsEmbedded => 'Şarkı sözleri başarıyla gömüldü'; String get trackLyricsEmbedded => 'Lyrics embedded successfully';
@override @override
String get trackInstrumental => 'Enstrümantal şarkı'; String get trackInstrumental => 'Instrumental track';
@override @override
String get trackCopiedToClipboard => 'Panoya kopyalandı'; String get trackCopiedToClipboard => 'Copied to clipboard';
@override @override
String get trackDeleteConfirmTitle => 'Cihazdan kaldırılsın mı?'; String get trackDeleteConfirmTitle => 'Remove from device?';
@override @override
String get trackDeleteConfirmMessage => String get trackDeleteConfirmMessage =>
'Bu işlem indirilen dosyayı kalıcı olarak silecek ve geçmişten kaldıracaktır.'; 'This will permanently delete the downloaded file and remove it from your history.';
@override @override
String get dateToday => 'Bugün'; String get dateToday => 'Today';
@override @override
String get dateYesterday => 'Dün'; String get dateYesterday => 'Yesterday';
@override @override
String dateDaysAgo(int count) { String dateDaysAgo(int count) {
return '$count gün önce'; return '$count days ago';
} }
@override @override
String dateWeeksAgo(int count) { String dateWeeksAgo(int count) {
return '$count hafta önce'; return '$count weeks ago';
} }
@override @override
String dateMonthsAgo(int count) { String dateMonthsAgo(int count) {
return '$count ay önce'; return '$count months ago';
} }
@override @override
String get storeFilterAll => 'Tümü'; String get storeFilterAll => 'All';
@override @override
String get storeFilterMetadata => 'Metadata'; String get storeFilterMetadata => 'Metadata';
@override @override
String get storeFilterDownload => 'İndirme'; String get storeFilterDownload => 'Download';
@override @override
String get storeFilterUtility => 'Araç'; String get storeFilterUtility => 'Utility';
@override @override
String get storeFilterLyrics => 'Şarkı Sözleri'; String get storeFilterLyrics => 'Lyrics';
@override @override
String get storeFilterIntegration => 'Entegrasyon'; String get storeFilterIntegration => 'Integration';
@override @override
String get storeClearFilters => 'Filtreleri temizle'; String get storeClearFilters => 'Clear filters';
@override @override
String get storeAddRepoTitle => 'Add Extension Repository'; String get storeAddRepoTitle => 'Add Extension Repository';
@@ -1251,137 +1246,136 @@ class AppLocalizationsTr extends AppLocalizations {
String get storeEmptyNoResults => 'No extensions found'; String get storeEmptyNoResults => 'No extensions found';
@override @override
String get extensionDefaultProvider => 'Varsayılan (Deezer/Spotify)'; String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
@override @override
String get extensionDefaultProviderSubtitle => 'Dahili aramayı kullan'; String get extensionDefaultProviderSubtitle => 'Use built-in search';
@override @override
String get extensionAuthor => 'Yazar'; String get extensionAuthor => 'Author';
@override @override
String get extensionId => 'ID'; String get extensionId => 'ID';
@override @override
String get extensionError => 'Hata'; String get extensionError => 'Error';
@override @override
String get extensionCapabilities => 'Yetenekler'; String get extensionCapabilities => 'Capabilities';
@override @override
String get extensionMetadataProvider => 'Metadata Sağlayıcı'; String get extensionMetadataProvider => 'Metadata Provider';
@override @override
String get extensionDownloadProvider => 'İndirme Sağlayıcı'; String get extensionDownloadProvider => 'Download Provider';
@override @override
String get extensionLyricsProvider => 'Şarkı Sözü Sağlayıcı'; String get extensionLyricsProvider => 'Lyrics Provider';
@override @override
String get extensionUrlHandler => 'URL İşleyici'; String get extensionUrlHandler => 'URL Handler';
@override @override
String get extensionQualityOptions => 'Kalite Seçenekleri'; String get extensionQualityOptions => 'Quality Options';
@override @override
String get extensionPostProcessingHooks => 'İşlem Sonrası Kancalar'; String get extensionPostProcessingHooks => 'Post-Processing Hooks';
@override @override
String get extensionPermissions => 'İzinler'; String get extensionPermissions => 'Permissions';
@override @override
String get extensionSettings => 'Ayarlar'; String get extensionSettings => 'Settings';
@override @override
String get extensionRemoveButton => 'Eklentiyi Kaldır'; String get extensionRemoveButton => 'Remove Extension';
@override @override
String get extensionUpdated => 'Güncellendi'; String get extensionUpdated => 'Updated';
@override @override
String get extensionMinAppVersion => 'Min Uygulama Sürümü'; String get extensionMinAppVersion => 'Min App Version';
@override @override
String get extensionCustomTrackMatching => 'Özel Şarkı Eşleştirme'; String get extensionCustomTrackMatching => 'Custom Track Matching';
@override @override
String get extensionPostProcessing => 'İşlem Sonrası'; String get extensionPostProcessing => 'Post-Processing';
@override @override
String extensionHooksAvailable(int count) { String extensionHooksAvailable(int count) {
return '$count kanca mevcut'; return '$count hook(s) available';
} }
@override @override
String extensionPatternsCount(int count) { String extensionPatternsCount(int count) {
return '$count desen'; return '$count pattern(s)';
} }
@override @override
String extensionStrategy(String strategy) { String extensionStrategy(String strategy) {
return 'Strateji: $strategy'; return 'Strategy: $strategy';
} }
@override @override
String get extensionsProviderPrioritySection => 'Sağlayıcı Önceliği'; String get extensionsProviderPrioritySection => 'Provider Priority';
@override @override
String get extensionsInstalledSection => 'Yüklü Eklentiler'; String get extensionsInstalledSection => 'Installed Extensions';
@override @override
String get extensionsNoExtensions => 'Yüklü eklenti yok'; String get extensionsNoExtensions => 'No extensions installed';
@override @override
String get extensionsNoExtensionsSubtitle => String get extensionsNoExtensionsSubtitle =>
'Yeni sağlayıcılar eklemek için .spotiflac-ext dosyalarını yükleyin'; 'Install .spotiflac-ext files to add new providers';
@override @override
String get extensionsInstallButton => 'Eklenti Yükle'; String get extensionsInstallButton => 'Install Extension';
@override @override
String get extensionsInfoTip => String get extensionsInfoTip =>
'Eklentiler yeni metadata ve indirme sağlayıcıları ekleyebilir. Sadece güvenilir kaynaklardan eklenti yükleyin.'; 'Extensions can add new metadata and download providers. Only install extensions from trusted sources.';
@override @override
String get extensionsInstalledSuccess => 'Eklenti başarıyla yüklendi'; String get extensionsInstalledSuccess => 'Extension installed successfully';
@override @override
String get extensionsDownloadPriority => 'İndirme Önceliği'; String get extensionsDownloadPriority => 'Download Priority';
@override @override
String get extensionsDownloadPrioritySubtitle => String get extensionsDownloadPrioritySubtitle => 'Set download service order';
'İndirme hizmeti sırasını ayarla';
@override @override
String get extensionsNoDownloadProvider => String get extensionsNoDownloadProvider =>
'İndirme sağlayıcısı olan eklenti yok'; 'No extensions with download provider';
@override @override
String get extensionsMetadataPriority => 'Metadata Önceliği'; String get extensionsMetadataPriority => 'Metadata Priority';
@override @override
String get extensionsMetadataPrioritySubtitle => String get extensionsMetadataPrioritySubtitle =>
'Arama ve metadata kaynağı sırasını ayarla'; 'Set search & metadata source order';
@override @override
String get extensionsNoMetadataProvider => String get extensionsNoMetadataProvider =>
'Metadata sağlayıcısı olan eklenti yok'; 'No extensions with metadata provider';
@override @override
String get extensionsSearchProvider => 'Arama Sağlayıcı'; String get extensionsSearchProvider => 'Search Provider';
@override @override
String get extensionsNoCustomSearch => 'Özel arama olan eklenti yok'; String get extensionsNoCustomSearch => 'No extensions with custom search';
@override @override
String get extensionsSearchProviderDescription => String get extensionsSearchProviderDescription =>
'Şarkı aramak için hangi hizmetin kullanılacağını seçin'; 'Choose which service to use for searching tracks';
@override @override
String get extensionsCustomSearch => 'Özel arama'; String get extensionsCustomSearch => 'Custom search';
@override @override
String get extensionsErrorLoading => 'Eklenti yüklenirken hata oluştu'; String get extensionsErrorLoading => 'Error loading extension';
@override @override
String get qualityFlacLossless => 'FLAC Lossless'; String get qualityFlacLossless => 'FLAC Lossless';
@@ -1401,6 +1395,38 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -1631,19 +1657,19 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String discographyAddedToQueue(int count) { String discographyAddedToQueue(int count) {
return '$count şarkı kuyruğa eklendi'; return 'Added $count tracks to queue';
} }
@override @override
String discographySkippedDownloaded(int added, int skipped) { String discographySkippedDownloaded(int added, int skipped) {
return '$added eklendi, $skipped zaten indirilmiş'; return '$added added, $skipped already downloaded';
} }
@override @override
String get discographyNoAlbums => 'Albüm mevcut değil'; String get discographyNoAlbums => 'No albums available';
@override @override
String get discographyFailedToFetch => 'Bazı albümler alınamadı'; String get discographyFailedToFetch => 'Failed to fetch some albums';
@override @override
String get sectionStorageAccess => 'Storage Access'; String get sectionStorageAccess => 'Storage Access';
@@ -1902,7 +1928,7 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -2696,6 +2722,22 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
+280 -113
View File
@@ -1389,6 +1389,38 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz'; String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadLossy320Format => 'Lossy 320kbps Format';
@override
String get downloadLossy320FormatDesc =>
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
@override
String get downloadLossyMp3 => 'MP3 320kbps';
@override
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
@override
String get downloadLossyOpus256 => 'Opus 256kbps';
@override
String get downloadLossyOpus256Subtitle =>
'Best quality Opus, ~8MB per track';
@override
String get downloadLossyOpus128 => 'Opus 128kbps';
@override
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
@override @override
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@@ -2685,6 +2717,22 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get downloadArtistNameFilters => 'Artist Name Filters'; String get downloadArtistNameFilters => 'Artist Name Filters';
@override
String get downloadCreatePlaylistSourceFolder =>
'Create playlist source folder';
@override
String get downloadCreatePlaylistSourceFolderEnabled =>
'Playlist downloads use Playlist/ plus your normal folder structure.';
@override
String get downloadCreatePlaylistSourceFolderDisabled =>
'Playlist downloads use the normal folder structure only.';
@override
String get downloadCreatePlaylistSourceFolderRedundant =>
'By Playlist already places downloads inside a playlist folder.';
@override @override
String get downloadSongLinkRegion => 'SongLink Region'; String get downloadSongLinkRegion => 'SongLink Region';
@@ -2925,294 +2973,283 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get appName => 'SpotiFLAC'; String get appName => 'SpotiFLAC';
@override @override
String get navHome => 'Home'; String get navHome => '主页';
@override @override
String get navLibrary => 'Library'; String get navLibrary => '乐库';
@override @override
String get navSettings => 'Settings'; String get navSettings => '设置';
@override @override
String get navStore => 'Store'; String get navStore => '商店';
@override @override
String get homeTitle => 'Home'; String get homeTitle => '主页';
@override @override
String get homeSubtitle => 'Paste a Spotify link or search by name'; String get homeSubtitle => '粘贴 Spotify 链接或按名称搜索';
@override @override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; String get homeSupports => '支持:歌曲、专辑、播放列表、艺人网址';
@override @override
String get homeRecent => 'Recent'; String get homeRecent => '最近';
@override @override
String get historyFilterAll => 'All'; String get historyFilterAll => '全部';
@override @override
String get historyFilterAlbums => 'Albums'; String get historyFilterAlbums => '专辑';
@override @override
String get historyFilterSingles => 'Singles'; String get historyFilterSingles => '单曲';
@override @override
String get historySearchHint => 'Search history...'; String get historySearchHint => '搜索历史……';
@override @override
String get settingsTitle => 'Settings'; String get settingsTitle => '设置';
@override @override
String get settingsDownload => 'Download'; String get settingsDownload => '下载';
@override @override
String get settingsAppearance => 'Appearance'; String get settingsAppearance => '外观';
@override @override
String get settingsOptions => 'Options'; String get settingsOptions => '选项';
@override @override
String get settingsExtensions => 'Extensions'; String get settingsExtensions => '扩展';
@override @override
String get settingsAbout => 'About'; String get settingsAbout => '关于';
@override @override
String get downloadTitle => 'Download'; String get downloadTitle => '下载';
@override @override
String get downloadAskQualitySubtitle => String get downloadAskQualitySubtitle => '为每次下载显示质量选择器';
'Show quality picker for each download';
@override @override
String get downloadFilenameFormat => 'Filename Format'; String get downloadFilenameFormat => '文件名格式';
@override @override
String get downloadFolderOrganization => 'Folder Organization'; String get downloadFolderOrganization => '文件夹结构';
@override @override
String get appearanceTitle => 'Appearance'; String get appearanceTitle => '外观';
@override @override
String get appearanceThemeSystem => 'System'; String get appearanceThemeSystem => '系统';
@override @override
String get appearanceThemeLight => 'Light'; String get appearanceThemeLight => '浅色';
@override @override
String get appearanceThemeDark => 'Dark'; String get appearanceThemeDark => '深色';
@override @override
String get appearanceDynamicColor => 'Dynamic Color'; String get appearanceDynamicColor => '动态色彩';
@override @override
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper'; String get appearanceDynamicColorSubtitle => '使用壁纸的颜色';
@override @override
String get appearanceHistoryView => 'History View'; String get appearanceHistoryView => '历史记录';
@override @override
String get appearanceHistoryViewList => 'List'; String get appearanceHistoryViewList => '列表';
@override @override
String get appearanceHistoryViewGrid => 'Grid'; String get appearanceHistoryViewGrid => '网格';
@override @override
String get optionsTitle => 'Options'; String get optionsTitle => '选项';
@override @override
String get optionsPrimaryProvider => 'Primary Provider'; String get optionsPrimaryProvider => '主要提供者';
@override @override
String get optionsPrimaryProviderSubtitle => String get optionsPrimaryProviderSubtitle => '按歌曲名称搜索时使用的服务。';
'Service used when searching by track name.';
@override @override
String optionsUsingExtension(String extensionName) { String optionsUsingExtension(String extensionName) {
return 'Using extension: $extensionName'; return '使用扩展:$extensionName';
} }
@override @override
String get optionsSwitchBack => String get optionsSwitchBack => '点击 Deezer 或 Spotify 即可从扩展程序切换回来';
'Tap Deezer or Spotify to switch back from extension';
@override @override
String get optionsAutoFallback => 'Auto Fallback'; String get optionsAutoFallback => '自动回退';
@override @override
String get optionsAutoFallbackSubtitle => String get optionsAutoFallbackSubtitle => '如果下载失败,请尝试其他服务';
'Try other services if download fails';
@override @override
String get optionsUseExtensionProviders => 'Use Extension Providers'; String get optionsUseExtensionProviders => '使用扩展提供商';
@override @override
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; String get optionsUseExtensionProvidersOn => '扩展会被最先尝试';
@override @override
String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; String get optionsUseExtensionProvidersOff => '仅使用内置提供商';
@override @override
String get optionsEmbedLyrics => 'Embed Lyrics'; String get optionsEmbedLyrics => '内嵌歌词';
@override @override
String get optionsEmbedLyricsSubtitle => String get optionsEmbedLyricsSubtitle => '嵌入已同步歌词到 FLAC 文件';
'Embed synced lyrics into FLAC files';
@override @override
String get optionsMaxQualityCover => 'Max Quality Cover'; String get optionsMaxQualityCover => '最高质量封面';
@override @override
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle => '下载最高分辨率封面';
'Download highest resolution cover art';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => '并行下载数';
@override @override
String get optionsConcurrentSequential => 'Sequential (1 at a time)'; String get optionsConcurrentSequential => '按顺序下载(一次一首)';
@override @override
String optionsConcurrentParallel(int count) { String optionsConcurrentParallel(int count) {
return '$count parallel downloads'; return '同时下载 $count';
} }
@override @override
String get optionsConcurrentWarning => String get optionsConcurrentWarning => '并行下载可能会触发速率限制';
'Parallel downloads may trigger rate limiting';
@override @override
String get optionsExtensionStore => 'Extension Store'; String get optionsExtensionStore => '扩展商店';
@override @override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; String get optionsExtensionStoreSubtitle => '在导航中显示商店标签';
@override @override
String get optionsCheckUpdates => 'Check for Updates'; String get optionsCheckUpdates => '检查更新';
@override @override
String get optionsCheckUpdatesSubtitle => String get optionsCheckUpdatesSubtitle => '当有新版本可用时通知';
'Notify when new version is available';
@override @override
String get optionsUpdateChannel => 'Update Channel'; String get optionsUpdateChannel => '更新频道';
@override @override
String get optionsUpdateChannelStable => 'Stable releases only'; String get optionsUpdateChannelStable => '仅稳定版本';
@override @override
String get optionsUpdateChannelPreview => 'Get preview releases'; String get optionsUpdateChannelPreview => '获取预览版本';
@override @override
String get optionsUpdateChannelWarning => String get optionsUpdateChannelWarning => '预览版本可能包含错误或者尚未完善的功能';
'Preview may contain bugs or incomplete features';
@override @override
String get optionsClearHistory => 'Clear Download History'; String get optionsClearHistory => '清除下载历史记录';
@override @override
String get optionsClearHistorySubtitle => String get optionsClearHistorySubtitle => '从历史记录中清除所有已下载的曲目';
'Remove all downloaded tracks from history';
@override @override
String get optionsDetailedLogging => 'Detailed Logging'; String get optionsDetailedLogging => '详细日志';
@override @override
String get optionsDetailedLoggingOn => 'Detailed logs are being recorded'; String get optionsDetailedLoggingOn => '正在记录详细日志';
@override @override
String get optionsDetailedLoggingOff => 'Enable for bug reports'; String get optionsDetailedLoggingOff => '为错误报告启用';
@override @override
String get optionsSpotifyCredentials => 'Spotify Credentials'; String get optionsSpotifyCredentials => 'Spotify 凭据';
@override @override
String optionsSpotifyCredentialsConfigured(String clientId) { String optionsSpotifyCredentialsConfigured(String clientId) {
return 'Client ID: $clientId...'; return '客户端 ID$clientId……';
} }
@override @override
String get optionsSpotifyCredentialsRequired => 'Required - tap to configure'; String get optionsSpotifyCredentialsRequired => '必填 - 点击配置';
@override @override
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify 需要您自己的 API 凭据。在 developer.spotify.com 免费获取';
@override @override
String get optionsSpotifyDeprecationWarning => String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'; 'Spotify 搜索将在 2026 年 3 月 3 日因 Spotify API 更改而被废弃。请切换到 Deezer';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => '扩展';
@override @override
String get extensionsDisabled => 'Disabled'; String get extensionsDisabled => '禁用';
@override @override
String extensionsVersion(String version) { String extensionsVersion(String version) {
return 'Version $version'; return '版本 $version';
} }
@override @override
String extensionsAuthor(String author) { String extensionsAuthor(String author) {
return 'by $author'; return '来自 $author';
} }
@override @override
String get extensionsUninstall => 'Uninstall'; String get extensionsUninstall => '卸载';
@override @override
String get storeTitle => 'Extension Store'; String get storeTitle => '扩展商店';
@override @override
String get storeSearch => 'Search extensions...'; String get storeSearch => '搜索扩展……';
@override @override
String get storeInstall => 'Install'; String get storeInstall => '安装';
@override @override
String get storeInstalled => 'Installed'; String get storeInstalled => '已安装';
@override @override
String get storeUpdate => 'Update'; String get storeUpdate => '更新';
@override @override
String get aboutTitle => 'About'; String get aboutTitle => '关于';
@override @override
String get aboutContributors => 'Contributors'; String get aboutContributors => '贡献者';
@override @override
String get aboutMobileDeveloper => 'Mobile version developer'; String get aboutMobileDeveloper => '移动版本开发者';
@override @override
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC'; String get aboutOriginalCreator => '原 SpotiLDAC 创建者';
@override @override
String get aboutLogoArtist => String get aboutLogoArtist => '有才华的艺术家创建了我们美丽的应用图标!';
'The talented artist who created our beautiful app logo!';
@override @override
String get aboutTranslators => 'Translators'; String get aboutTranslators => '译者';
@override @override
String get aboutSpecialThanks => 'Special Thanks'; String get aboutSpecialThanks => '特别鸣谢';
@override @override
String get aboutLinks => 'Links'; String get aboutLinks => '相关链接';
@override @override
String get aboutMobileSource => 'Mobile source code'; String get aboutMobileSource => '移动版本源代码';
@override @override
String get aboutPCSource => 'PC source code'; String get aboutPCSource => '桌面版本源代码';
@override @override
String get aboutReportIssue => 'Report an issue'; String get aboutReportIssue => '报告一个问题';
@override @override
String get aboutReportIssueSubtitle => 'Report any problems you encounter'; String get aboutReportIssueSubtitle => '报告您遇到的任何问题';
@override @override
String get aboutFeatureRequest => 'Feature request'; String get aboutFeatureRequest => 'Feature request';
@@ -3269,7 +3306,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@@ -3385,20 +3422,19 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get setupNotificationGranted => 'Notification Permission Granted!'; String get setupNotificationGranted => 'Notification Permission Granted!';
@override @override
String get setupNotificationEnable => 'Enable Notifications'; String get setupNotificationEnable => '启用通知';
@override @override
String get setupFolderChoose => 'Choose Download Folder'; String get setupFolderChoose => '选择下载文件夹';
@override @override
String get setupFolderDescription => String get setupFolderDescription => '选择保存您下载的音乐的文件夹。';
'Select a folder where your downloaded music will be saved.';
@override @override
String get setupSelectFolder => 'Select Folder'; String get setupSelectFolder => '选择文件夹';
@override @override
String get setupEnableNotifications => 'Enable Notifications'; String get setupEnableNotifications => '启用通知';
@override @override
String get setupNotificationBackgroundDescription => String get setupNotificationBackgroundDescription =>
@@ -3595,11 +3631,21 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get errorRateLimited => 'Rate Limited'; String get errorRateLimited => 'Rate Limited';
@override @override
String get errorRateLimitedMessage => String get errorRateLimitedMessage => '请求过多。请等一会再搜索。';
'Too many requests. Please wait a moment before searching again.';
@override @override
String get errorNoTracksFound => 'No tracks found'; String get errorNoTracksFound => '未找到曲目';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override @override
String errorMissingExtensionSource(String item) { String errorMissingExtensionSource(String item) {
@@ -3674,6 +3720,13 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override @override
String get folderOrganizationNone => 'No organization'; String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override @override
String get folderOrganizationByArtist => 'By Artist'; String get folderOrganizationByArtist => 'By Artist';
@@ -4722,7 +4775,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -5039,6 +5092,54 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override @override
String get trackConvertFailed => 'Conversion failed'; String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override @override
String get actionCreate => 'Create'; String get actionCreate => 'Create';
@@ -5609,7 +5710,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
@override @override
String get artistAlbums => 'Albums'; String get artistAlbums => 'Albums';
@@ -5941,6 +6042,17 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get errorNoTracksFound => 'No tracks found'; String get errorNoTracksFound => 'No tracks found';
@override
String get errorUrlNotRecognized => 'Link not recognized';
@override
String get errorUrlNotRecognizedMessage =>
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
@override
String get errorUrlFetchFailed =>
'Failed to load content from this link. Please try again.';
@override @override
String errorMissingExtensionSource(String item) { String errorMissingExtensionSource(String item) {
return 'Cannot load $item: missing extension source'; return 'Cannot load $item: missing extension source';
@@ -6014,6 +6126,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get folderOrganizationNone => 'No organization'; String get folderOrganizationNone => 'No organization';
@override
String get folderOrganizationByPlaylist => 'By Playlist';
@override
String get folderOrganizationByPlaylistSubtitle =>
'Separate folder for each playlist';
@override @override
String get folderOrganizationByArtist => 'By Artist'; String get folderOrganizationByArtist => 'By Artist';
@@ -7062,7 +7181,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get tutorialWelcomeTip2 => String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; 'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
@override @override
String get tutorialWelcomeTip3 => String get tutorialWelcomeTip3 =>
@@ -7379,6 +7498,54 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get trackConvertFailed => 'Conversion failed'; String get trackConvertFailed => 'Conversion failed';
@override
String get cueSplitTitle => 'Split CUE Sheet';
@override
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
@override
String cueSplitAlbum(String album) {
return 'Album: $album';
}
@override
String cueSplitArtist(String artist) {
return 'Artist: $artist';
}
@override
String cueSplitTrackCount(int count) {
return '$count tracks';
}
@override
String get cueSplitConfirmTitle => 'Split CUE Album';
@override
String cueSplitConfirmMessage(String album, int count) {
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
}
@override
String cueSplitSplitting(int current, int total) {
return 'Splitting CUE sheet... ($current/$total)';
}
@override
String cueSplitSuccess(int count) {
return 'Split into $count tracks successfully';
}
@override
String get cueSplitFailed => 'CUE split failed';
@override
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
@override
String get cueSplitButton => 'Split into Tracks';
@override @override
String get actionCreate => 'Create'; String get actionCreate => 'Create';
+143 -39
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.", "aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -555,7 +555,7 @@
"@setupDownloadLocationTitle": { "@setupDownloadLocationTitle": {
"description": "Download location dialog title" "description": "Download location dialog title"
}, },
"setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.", "setupDownloadLocationIosMessage": "Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.",
"@setupDownloadLocationIosMessage": { "@setupDownloadLocationIosMessage": {
"description": "iOS-specific folder info" "description": "iOS-specific folder info"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link wurde nicht erkannt",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Laden fehlgeschlagen. Bitte erneut versuchen.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle", "errorMissingExtensionSource": "Kann {item} nicht lade wegen fehlender Erweiterungsquelle",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -947,7 +959,7 @@
"@selectionAllSelected": { "@selectionAllSelected": {
"description": "Status - all items selected" "description": "Status - all items selected"
}, },
"selectionSelectToDelete": "Titel zum Löschen auswählen", "selectionSelectToDelete": "Titel zum Löschen wählen",
"@selectionSelectToDelete": { "@selectionSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
@@ -975,7 +987,7 @@
"@searchArtists": { "@searchArtists": {
"description": "Search result category - artists" "description": "Search result category - artists"
}, },
"searchAlbums": "Albums", "searchAlbums": "Alben",
"@searchAlbums": { "@searchAlbums": {
"description": "Search result category - albums" "description": "Search result category - albums"
}, },
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "Nach Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Ordner für jede Playlist trennen",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Nach Künstler", "folderOrganizationByArtist": "Nach Künstler",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1019,7 +1039,7 @@
"@folderOrganizationDescription": { "@folderOrganizationDescription": {
"description": "Folder organization sheet description" "description": "Folder organization sheet description"
}, },
"folderOrganizationNoneSubtitle": "Alle Dateien im Download-Verzeichnis", "folderOrganizationNoneSubtitle": "Alle Dateien im Download-Ordner",
"@folderOrganizationNoneSubtitle": { "@folderOrganizationNoneSubtitle": {
"description": "Subtitle for no organization option" "description": "Subtitle for no organization option"
}, },
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Integriert", "providerBuiltIn": "Integriert",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Erweiterung", "providerExtension": "Erweiterung",
"@providerExtension": { "@providerExtension": {
@@ -1769,7 +1789,7 @@
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
}, },
"downloadDirectory": "Downloadverzeichnis", "downloadDirectory": "Download-Ordner",
"@downloadDirectory": { "@downloadDirectory": {
"description": "Setting - download folder" "description": "Setting - download folder"
}, },
@@ -1777,15 +1797,15 @@
"@downloadSeparateSinglesFolder": { "@downloadSeparateSinglesFolder": {
"description": "Setting - separate folder for singles" "description": "Setting - separate folder for singles"
}, },
"downloadAlbumFolderStructure": "Album Folder Structure", "downloadAlbumFolderStructure": "Album-Ordnerstruktur",
"@downloadAlbumFolderStructure": { "@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization" "description": "Setting - album folder organization"
}, },
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders", "downloadUseAlbumArtistForFolders": "Album-Künstler für Ordner verwenden",
"@downloadUseAlbumArtistForFolders": { "@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist" "description": "Setting - choose whether artist folders use Album Artist or Track Artist"
}, },
"downloadUsePrimaryArtistOnly": "Primary artist only for folders", "downloadUsePrimaryArtistOnly": "Primärer Künstler nur für Ordner",
"@downloadUsePrimaryArtistOnly": { "@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name" "description": "Setting - strip featured artists from folder name"
}, },
@@ -1793,7 +1813,7 @@
"@downloadUsePrimaryArtistOnlyEnabled": { "@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled" "description": "Subtitle when primary artist only is enabled"
}, },
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", "downloadUsePrimaryArtistOnlyDisabled": "Vollständiger Künstler für Ordnername",
"@downloadUsePrimaryArtistOnlyDisabled": { "@downloadUsePrimaryArtistOnlyDisabled": {
"description": "Subtitle when primary artist only is disabled" "description": "Subtitle when primary artist only is disabled"
}, },
@@ -1821,7 +1841,7 @@
"@queueClearAllMessage": { "@queueClearAllMessage": {
"description": "Clear queue confirmation" "description": "Clear queue confirmation"
}, },
"settingsAutoExportFailed": "Auto-export failed downloads", "settingsAutoExportFailed": "Auto-Export fehlgeschlagener Downloads",
"@settingsAutoExportFailed": { "@settingsAutoExportFailed": {
"description": "Setting toggle for auto-export" "description": "Setting toggle for auto-export"
}, },
@@ -1849,15 +1869,15 @@
"@albumFolderArtistAlbum": { "@albumFolderArtistAlbum": {
"description": "Album folder option" "description": "Album folder option"
}, },
"albumFolderArtistAlbumSubtitle": "Albums/Artist Name/Album Name/", "albumFolderArtistAlbumSubtitle": "Alben/Künster Name/Album Name/",
"@albumFolderArtistAlbumSubtitle": { "@albumFolderArtistAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistYearAlbum": "Artist / [Year] Album", "albumFolderArtistYearAlbum": "Künstler / [Year] Album",
"@albumFolderArtistYearAlbum": { "@albumFolderArtistYearAlbum": {
"description": "Album folder option with year" "description": "Album folder option with year"
}, },
"albumFolderArtistYearAlbumSubtitle": "Albums/Künster Name/[2005] Album Name/", "albumFolderArtistYearAlbumSubtitle": "Alben/Künster Name/[2005] Album Name/",
"@albumFolderArtistYearAlbumSubtitle": { "@albumFolderArtistYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
@@ -1873,15 +1893,15 @@
"@albumFolderYearAlbum": { "@albumFolderYearAlbum": {
"description": "Album folder option with year" "description": "Album folder option with year"
}, },
"albumFolderYearAlbumSubtitle": "Albums/[2005] Album Name/", "albumFolderYearAlbumSubtitle": "Alben/[2005] Album Name/",
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles", "albumFolderArtistAlbumSingles": "Künstler / Album + Singles",
"@albumFolderArtistAlbumSingles": { "@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist" "description": "Album folder option with singles inside artist"
}, },
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", "albumFolderArtistAlbumSinglesSubtitle": "Künstler/Album/ und Künstler/Singles/",
"@albumFolderArtistAlbumSinglesSubtitle": { "@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
@@ -1924,7 +1944,7 @@
} }
} }
}, },
"downloadedAlbumSelectToDelete": "Select tracks to delete", "downloadedAlbumSelectToDelete": "Titel zum Löschen wählen",
"@downloadedAlbumSelectToDelete": { "@downloadedAlbumSelectToDelete": {
"description": "Placeholder when nothing selected" "description": "Placeholder when nothing selected"
}, },
@@ -1996,7 +2016,7 @@
"@discographyAlbumsOnly": { "@discographyAlbumsOnly": {
"description": "Option - download only albums" "description": "Option - download only albums"
}, },
"discographyAlbumsOnlySubtitle": "{count} Titel von {albumCount} Albums", "discographyAlbumsOnlySubtitle": "{count} Titel aus {albumCount} Alben",
"@discographyAlbumsOnlySubtitle": { "@discographyAlbumsOnlySubtitle": {
"description": "Subtitle showing album tracks count", "description": "Subtitle showing album tracks count",
"placeholders": { "placeholders": {
@@ -2028,7 +2048,7 @@
"@discographySelectAlbums": { "@discographySelectAlbums": {
"description": "Option - manually select albums to download" "description": "Option - manually select albums to download"
}, },
"discographySelectAlbumsSubtitle": "Choose specific albums or singles", "discographySelectAlbumsSubtitle": "Wähle bestimmte Alben oder Singles",
"@discographySelectAlbumsSubtitle": { "@discographySelectAlbumsSubtitle": {
"description": "Subtitle for select albums option" "description": "Subtitle for select albums option"
}, },
@@ -2036,7 +2056,7 @@
"@discographyFetchingTracks": { "@discographyFetchingTracks": {
"description": "Progress - fetching album tracks" "description": "Progress - fetching album tracks"
}, },
"discographyFetchingAlbum": "Fetching {current} of {total}...", "discographyFetchingAlbum": "Lade {current} von {total}...",
"@discographyFetchingAlbum": { "@discographyFetchingAlbum": {
"description": "Progress - fetching specific album", "description": "Progress - fetching specific album",
"placeholders": { "placeholders": {
@@ -2061,7 +2081,7 @@
"@discographyDownloadSelected": { "@discographyDownloadSelected": {
"description": "Button - download selected albums" "description": "Button - download selected albums"
}, },
"discographyAddedToQueue": "Added {count} tracks to queue", "discographyAddedToQueue": "{count} Titel zur Warteschlange hinzugefügt",
"@discographyAddedToQueue": { "@discographyAddedToQueue": {
"description": "Snackbar - tracks added from discography", "description": "Snackbar - tracks added from discography",
"placeholders": { "placeholders": {
@@ -2086,7 +2106,7 @@
"@discographyNoAlbums": { "@discographyNoAlbums": {
"description": "Error - no albums found for artist" "description": "Error - no albums found for artist"
}, },
"discographyFailedToFetch": "Failed to fetch some albums", "discographyFailedToFetch": "Fehler beim Abrufen einiger Alben",
"@discographyFailedToFetch": { "@discographyFailedToFetch": {
"description": "Error - some albums failed to load" "description": "Error - some albums failed to load"
}, },
@@ -2098,15 +2118,15 @@
"@allFilesAccess": { "@allFilesAccess": {
"description": "Toggle for MANAGE_EXTERNAL_STORAGE permission" "description": "Toggle for MANAGE_EXTERNAL_STORAGE permission"
}, },
"allFilesAccessEnabledSubtitle": "Can write to any folder", "allFilesAccessEnabledSubtitle": "Darf in jeden Ordner schreiben",
"@allFilesAccessEnabledSubtitle": { "@allFilesAccessEnabledSubtitle": {
"description": "Subtitle when all files access is enabled" "description": "Subtitle when all files access is enabled"
}, },
"allFilesAccessDisabledSubtitle": "Limited to media folders only", "allFilesAccessDisabledSubtitle": "Nur auf Medienordner begrenzt",
"@allFilesAccessDisabledSubtitle": { "@allFilesAccessDisabledSubtitle": {
"description": "Subtitle when all files access is disabled" "description": "Subtitle when all files access is disabled"
}, },
"allFilesAccessDescription": "Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.", "allFilesAccessDescription": "Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).",
"@allFilesAccessDescription": { "@allFilesAccessDescription": {
"description": "Description explaining when to enable all files access" "description": "Description explaining when to enable all files access"
}, },
@@ -2122,7 +2142,7 @@
"@settingsLocalLibrary": { "@settingsLocalLibrary": {
"description": "Settings menu item - local library" "description": "Settings menu item - local library"
}, },
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates", "settingsLocalLibrarySubtitle": "Musik scannen & Duplikate erkennen",
"@settingsLocalLibrarySubtitle": { "@settingsLocalLibrarySubtitle": {
"description": "Subtitle for local library settings" "description": "Subtitle for local library settings"
}, },
@@ -2130,7 +2150,7 @@
"@settingsCache": { "@settingsCache": {
"description": "Settings menu item - cache management" "description": "Settings menu item - cache management"
}, },
"settingsCacheSubtitle": "View size and clear cached data", "settingsCacheSubtitle": "Größe anzeigen und Daten im Cache leeren",
"@settingsCacheSubtitle": { "@settingsCacheSubtitle": {
"description": "Subtitle for cache management menu" "description": "Subtitle for cache management menu"
}, },
@@ -2146,7 +2166,7 @@
"@libraryEnableLocalLibrary": { "@libraryEnableLocalLibrary": {
"description": "Toggle to enable library scanning" "description": "Toggle to enable library scanning"
}, },
"libraryEnableLocalLibrarySubtitle": "Scan and track your existing music", "libraryEnableLocalLibrarySubtitle": "Scan und verfolge deine bestehende Musik",
"@libraryEnableLocalLibrarySubtitle": { "@libraryEnableLocalLibrarySubtitle": {
"description": "Subtitle for enable toggle" "description": "Subtitle for enable toggle"
}, },
@@ -2158,7 +2178,7 @@
"@libraryFolderHint": { "@libraryFolderHint": {
"description": "Placeholder when no folder selected" "description": "Placeholder when no folder selected"
}, },
"libraryShowDuplicateIndicator": "Show Duplicate Indicator", "libraryShowDuplicateIndicator": "Duplikat Indikator anzeigen",
"@libraryShowDuplicateIndicator": { "@libraryShowDuplicateIndicator": {
"description": "Toggle for duplicate indicator in search" "description": "Toggle for duplicate indicator in search"
}, },
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik", "tutorialWelcomeTip2": "Hole dir FLAC Audio von Tidal, Qobuz oder Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2455,7 +2475,7 @@
"@tutorialSettingsDesc": { "@tutorialSettingsDesc": {
"description": "Tutorial settings page description" "description": "Tutorial settings page description"
}, },
"tutorialSettingsTip1": "Downloadverzeichnis und Ordnerorganisation ändern", "tutorialSettingsTip1": "Download-Ordner und Ordner-Organisation ändern",
"@tutorialSettingsTip1": { "@tutorialSettingsTip1": {
"description": "Tutorial settings tip 1" "description": "Tutorial settings tip 1"
}, },
@@ -2529,7 +2549,7 @@
"@cacheSectionMaintenance": { "@cacheSectionMaintenance": {
"description": "Section header for cleanup actions" "description": "Section header for cleanup actions"
}, },
"cacheAppDirectory": "App-Cache Verzeichnis", "cacheAppDirectory": "App-Cache Ordner",
"@cacheAppDirectory": { "@cacheAppDirectory": {
"description": "Cache item title for app cache directory" "description": "Cache item title for app cache directory"
}, },
@@ -2537,7 +2557,7 @@
"@cacheAppDirectoryDesc": { "@cacheAppDirectoryDesc": {
"description": "Description of what app cache directory contains" "description": "Description of what app cache directory contains"
}, },
"cacheTempDirectory": "Temporäres Verzeichnis", "cacheTempDirectory": "Temporärer Ordner",
"@cacheTempDirectory": { "@cacheTempDirectory": {
"description": "Cache item title for temporary files directory" "description": "Cache item title for temporary files directory"
}, },
@@ -2705,7 +2725,7 @@
"@trackEditMetadata": { "@trackEditMetadata": {
"description": "Menu action - edit embedded metadata" "description": "Menu action - edit embedded metadata"
}, },
"trackCoverSaved": "Cover art saved to {fileName}", "trackCoverSaved": "Cover in {fileName} gespeichert",
"@trackCoverSaved": { "@trackCoverSaved": {
"description": "Snackbar after cover art saved", "description": "Snackbar after cover art saved",
"placeholders": { "placeholders": {
@@ -2714,7 +2734,7 @@
} }
} }
}, },
"trackCoverNoSource": "No cover art source available", "trackCoverNoSource": "Keine Cover Quelle vorhanden",
"@trackCoverNoSource": { "@trackCoverNoSource": {
"description": "Snackbar when no cover art URL or embedded cover" "description": "Snackbar when no cover art URL or embedded cover"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "CUE-Sheet aufteilen",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "CUE+FLAC in einzelne Titel aufteilen",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Künstler: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} Titel",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "CUE-Album aufteilen",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Soll „{album}“ in {count} einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "CUE-Sheet wird geteilt... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "{count} Titel erfolgreich aufgeteilt",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE-Aufteilung fehlgeschlagen",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audiodatei für dieses CUE-Sheet nicht gefunden",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "In Titel aufteilen",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Erstellen", "actionCreate": "Erstellen",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
@@ -3094,11 +3198,11 @@
} }
} }
}, },
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Künstlerordner verwenden den Album-Interpreten, wenn verfügbar", "downloadUseAlbumArtistForFoldersAlbumSubtitle": "Interpret-Ordner verwenden Album-Interpret, sofern vorhanden",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": { "@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
"description": "Subtitle when Album Artist is used for folder naming" "description": "Subtitle when Album Artist is used for folder naming"
}, },
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only", "downloadUseAlbumArtistForFoldersTrackSubtitle": "Künstler-Ordner nur für Titel-Künstler",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": { "@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming" "description": "Subtitle when Track Artist is used for folder naming"
} }
+57 -1
View File
@@ -1626,7 +1626,7 @@
"@storeEmptyNoResults": { "@storeEmptyNoResults": {
"description": "Message when search/filter returns no results" "description": "Message when search/filter returns no results"
}, },
"extensionDefaultProvider": "Default (Deezer/Spotify)", "extensionDefaultProvider": "Default (Deezer)",
"@extensionDefaultProvider": { "@extensionDefaultProvider": {
"description": "Default search provider option" "description": "Default search provider option"
}, },
@@ -1825,6 +1825,46 @@
"@qualityHiResFlacMaxSubtitle": { "@qualityHiResFlacMaxSubtitle": {
"description": "Technical spec for hi-res max" "description": "Technical spec for hi-res max"
}, },
"downloadLossy320": "Lossy 320kbps",
"@downloadLossy320": {
"description": "Quality option label for Tidal lossy 320kbps"
},
"downloadLossyFormat": "Lossy Format",
"@downloadLossyFormat": {
"description": "Setting title to pick output format for Tidal lossy downloads"
},
"downloadLossy320Format": "Lossy 320kbps Format",
"@downloadLossy320Format": {
"description": "Title of the Tidal lossy format picker bottom sheet"
},
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
"@downloadLossy320FormatDesc": {
"description": "Description in the Tidal lossy format picker"
},
"downloadLossyMp3": "MP3 320kbps",
"@downloadLossyMp3": {
"description": "Tidal lossy format option - MP3 320kbps"
},
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
"@downloadLossyMp3Subtitle": {
"description": "Subtitle for MP3 320kbps Tidal lossy option"
},
"downloadLossyOpus256": "Opus 256kbps",
"@downloadLossyOpus256": {
"description": "Tidal lossy format option - Opus 256kbps"
},
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
"@downloadLossyOpus256Subtitle": {
"description": "Subtitle for Opus 256kbps Tidal lossy option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps Tidal lossy option"
},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
@@ -3586,6 +3626,22 @@
"@downloadArtistNameFilters": { "@downloadArtistNameFilters": {
"description": "Setting title for artist folder filter options" "description": "Setting title for artist folder filter options"
}, },
"downloadCreatePlaylistSourceFolder": "Create playlist source folder",
"@downloadCreatePlaylistSourceFolder": {
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
},
"downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
"@downloadCreatePlaylistSourceFolderEnabled": {
"description": "Subtitle when playlist source folder prefix is enabled"
},
"downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
"@downloadCreatePlaylistSourceFolderDisabled": {
"description": "Subtitle when playlist source folder prefix is disabled"
},
"downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
"@downloadCreatePlaylistSourceFolderRedundant": {
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
},
"downloadSongLinkRegion": "SongLink Region", "downloadSongLinkRegion": "SongLink Region",
"@downloadSongLinkRegion": { "@downloadSongLinkRegion": {
"description": "Setting title for SongLink country region" "description": "Setting title for SongLink country region"
+409 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión", "errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -991,10 +1003,26 @@
"@filenameFormat": { "@filenameFormat": {
"description": "Setting title - filename pattern" "description": "Setting title - filename pattern"
}, },
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "Ninguna organización", "folderOrganizationNone": "Ninguna organización",
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Por Artista", "folderOrganizationByArtist": "Por Artista",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1749,6 +1777,14 @@
"@youtubeQualityNote": { "@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality" "description": "Note for YouTube service explaining lossy-only quality"
}, },
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Preguntar antes de descargar", "downloadAskBeforeDownload": "Preguntar antes de descargar",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
@@ -2198,6 +2234,15 @@
"@libraryAboutDescription": { "@libraryAboutDescription": {
"description": "Description of local library feature" "description": "Description of local library feature"
}, },
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}", "libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": { "@libraryLastScanned": {
"description": "Last scan time display", "description": "Last scan time display",
@@ -2358,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2783,6 +2828,367 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} descargado", "downloadedAlbumDownloadedCount": "{count} descargado",
"@downloadedAlbumDownloadedCount": { "@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge", "description": "Downloaded tracks count badge",
@@ -2800,4 +3206,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": { "@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming" "description": "Subtitle when Track Artist is used for folder naming"
} }
} }
+107 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source", "errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist", "folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
+107 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source", "errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist", "folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
+162 -54
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", "aboutAppDescription": "Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -1003,11 +1003,11 @@
"@filenameFormat": { "@filenameFormat": {
"description": "Setting title - filename pattern" "description": "Setting title - filename pattern"
}, },
"filenameShowAdvancedTags": "Show advanced tags", "filenameShowAdvancedTags": "Tampilkan tag lanjutan",
"@filenameShowAdvancedTags": { "@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags" "description": "Toggle label for showing advanced filename tags"
}, },
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns", "filenameShowAdvancedTagsDescription": "Aktifkan tag yang diformat untuk padding trek dan pola tanggal",
"@filenameShowAdvancedTagsDescription": { "@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle" "description": "Description for advanced filename tag toggle"
}, },
@@ -1015,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "Berdasarkan Daftar Putar",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Setiap daftar putar memerlukan folder terpisah",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Berdasarkan Artis", "folderOrganizationByArtist": "Berdasarkan Artis",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1109,7 +1117,7 @@
}, },
"providerBuiltIn": "Bawaan", "providerBuiltIn": "Bawaan",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Ekstensi", "providerExtension": "Ekstensi",
"@providerExtension": { "@providerExtension": {
@@ -1209,7 +1217,7 @@
"@credentialsDescription": { "@credentialsDescription": {
"description": "Credentials dialog explanation" "description": "Credentials dialog explanation"
}, },
"credentialsClientId": "Client ID", "credentialsClientId": "ID Klien",
"@credentialsClientId": { "@credentialsClientId": {
"description": "Client ID field label - DO NOT TRANSLATE" "description": "Client ID field label - DO NOT TRANSLATE"
}, },
@@ -1217,7 +1225,7 @@
"@credentialsClientIdHint": { "@credentialsClientIdHint": {
"description": "Client ID placeholder" "description": "Client ID placeholder"
}, },
"credentialsClientSecret": "Client Secret", "credentialsClientSecret": "Rahasia Klien",
"@credentialsClientSecret": { "@credentialsClientSecret": {
"description": "Client Secret field label - DO NOT TRANSLATE" "description": "Client Secret field label - DO NOT TRANSLATE"
}, },
@@ -1229,7 +1237,7 @@
"@channelStable": { "@channelStable": {
"description": "Update channel - stable releases" "description": "Update channel - stable releases"
}, },
"channelPreview": "Preview", "channelPreview": "Pratinjau",
"@channelPreview": { "@channelPreview": {
"description": "Update channel - beta/preview releases" "description": "Update channel - beta/preview releases"
}, },
@@ -1269,39 +1277,39 @@
"@sectionFileSettings": { "@sectionFileSettings": {
"description": "Settings section header" "description": "Settings section header"
}, },
"sectionLyrics": "Lyrics", "sectionLyrics": "Lirik",
"@sectionLyrics": { "@sectionLyrics": {
"description": "Settings section header" "description": "Settings section header"
}, },
"lyricsMode": "Lyrics Mode", "lyricsMode": "Mode Lirik",
"@lyricsMode": { "@lyricsMode": {
"description": "Setting - how to save lyrics" "description": "Setting - how to save lyrics"
}, },
"lyricsModeDescription": "Choose how lyrics are saved with your downloads", "lyricsModeDescription": "Pilih cara lirik disimpan bersama unduhan Anda",
"@lyricsModeDescription": { "@lyricsModeDescription": {
"description": "Lyrics mode picker description" "description": "Lyrics mode picker description"
}, },
"lyricsModeEmbed": "Embed in file", "lyricsModeEmbed": "Sematkan dalam file",
"@lyricsModeEmbed": { "@lyricsModeEmbed": {
"description": "Lyrics mode option - embed in audio file" "description": "Lyrics mode option - embed in audio file"
}, },
"lyricsModeEmbedSubtitle": "Lyrics stored inside FLAC metadata", "lyricsModeEmbedSubtitle": "Lirik tersimpan di dalam metadata FLAC",
"@lyricsModeEmbedSubtitle": { "@lyricsModeEmbedSubtitle": {
"description": "Subtitle for embed option" "description": "Subtitle for embed option"
}, },
"lyricsModeExternal": "External .lrc file", "lyricsModeExternal": "File .lrc eksternal",
"@lyricsModeExternal": { "@lyricsModeExternal": {
"description": "Lyrics mode option - separate LRC file" "description": "Lyrics mode option - separate LRC file"
}, },
"lyricsModeExternalSubtitle": "Separate .lrc file for players like Samsung Music", "lyricsModeExternalSubtitle": "File .lrc terpisah untuk pemutar musik seperti Samsung Music",
"@lyricsModeExternalSubtitle": { "@lyricsModeExternalSubtitle": {
"description": "Subtitle for external option" "description": "Subtitle for external option"
}, },
"lyricsModeBoth": "Both", "lyricsModeBoth": "Keduanya",
"@lyricsModeBoth": { "@lyricsModeBoth": {
"description": "Lyrics mode option - embed and external" "description": "Lyrics mode option - embed and external"
}, },
"lyricsModeBothSubtitle": "Embed and save .lrc file", "lyricsModeBothSubtitle": "Sematkan dan simpan file .lrc",
"@lyricsModeBothSubtitle": { "@lyricsModeBothSubtitle": {
"description": "Subtitle for both option" "description": "Subtitle for both option"
}, },
@@ -1447,11 +1455,11 @@
"@trackGenre": { "@trackGenre": {
"description": "Metadata label - music genre" "description": "Metadata label - music genre"
}, },
"trackLabel": "Label", "trackLabel": "Lebel",
"@trackLabel": { "@trackLabel": {
"description": "Metadata label - record label" "description": "Metadata label - record label"
}, },
"trackCopyright": "Copyright", "trackCopyright": "Hak cipta",
"@trackCopyright": { "@trackCopyright": {
"description": "Metadata label - copyright information" "description": "Metadata label - copyright information"
}, },
@@ -1475,15 +1483,15 @@
"@trackLyricsLoadFailed": { "@trackLyricsLoadFailed": {
"description": "Message when lyrics loading fails" "description": "Message when lyrics loading fails"
}, },
"trackEmbedLyrics": "Embed Lyrics", "trackEmbedLyrics": "Sematkan Lirik",
"@trackEmbedLyrics": { "@trackEmbedLyrics": {
"description": "Action - embed lyrics into audio file" "description": "Action - embed lyrics into audio file"
}, },
"trackLyricsEmbedded": "Lyrics embedded successfully", "trackLyricsEmbedded": "Lirik berhasil disematkan",
"@trackLyricsEmbedded": { "@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file" "description": "Snackbar - lyrics saved to file"
}, },
"trackInstrumental": "Instrumental track", "trackInstrumental": "Lagu instrumental",
"@trackInstrumental": { "@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)" "description": "Message when track is instrumental (no lyrics)"
}, },
@@ -1562,7 +1570,7 @@
"@storeClearFilters": { "@storeClearFilters": {
"description": "Button to clear all filters" "description": "Button to clear all filters"
}, },
"extensionDefaultProvider": "Default (Deezer/Spotify)", "extensionDefaultProvider": "Bawaan (Deezer/Spotify)",
"@extensionDefaultProvider": { "@extensionDefaultProvider": {
"description": "Default search provider option" "description": "Default search provider option"
}, },
@@ -1578,7 +1586,7 @@
"@extensionId": { "@extensionId": {
"description": "Extension detail - unique ID" "description": "Extension detail - unique ID"
}, },
"extensionError": "Error", "extensionError": "Terjadi kesalahan",
"@extensionError": { "@extensionError": {
"description": "Extension detail - error message" "description": "Extension detail - error message"
}, },
@@ -1765,15 +1773,15 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", "youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.",
"@youtubeQualityNote": { "@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality" "description": "Note for YouTube service explaining lossy-only quality"
}, },
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate", "youtubeOpusBitrateTitle": "Bitrate YouTube Opus",
"@youtubeOpusBitrateTitle": { "@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting" "description": "Title for YouTube Opus bitrate setting"
}, },
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", "youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube",
"@youtubeMp3BitrateTitle": { "@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting" "description": "Title for YouTube MP3 bitrate setting"
}, },
@@ -1793,19 +1801,35 @@
"@downloadAlbumFolderStructure": { "@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization" "description": "Setting - album folder organization"
}, },
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders", "downloadUseAlbumArtistForFolders": "Gunakan Artis Album untuk folder",
"@downloadUseAlbumArtistForFolders": { "@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist" "description": "Setting - choose whether artist folders use Album Artist or Track Artist"
}, },
"downloadUsePrimaryArtistOnly": "Primary artist only for folders", "downloadCreatePlaylistSourceFolder": "Buat folder sumber playlist",
"@downloadCreatePlaylistSourceFolder": {
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
},
"downloadCreatePlaylistSourceFolderEnabled": "Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.",
"@downloadCreatePlaylistSourceFolderEnabled": {
"description": "Subtitle when playlist source folder prefix is enabled"
},
"downloadCreatePlaylistSourceFolderDisabled": "Unduhan dari playlist hanya memakai struktur folder normal.",
"@downloadCreatePlaylistSourceFolderDisabled": {
"description": "Subtitle when playlist source folder prefix is disabled"
},
"downloadCreatePlaylistSourceFolderRedundant": "Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.",
"@downloadCreatePlaylistSourceFolderRedundant": {
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
},
"downloadUsePrimaryArtistOnly": "Hanya artis utama untuk folder",
"@downloadUsePrimaryArtistOnly": { "@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name" "description": "Setting - strip featured artists from folder name"
}, },
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", "downloadUsePrimaryArtistOnlyEnabled": "Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)",
"@downloadUsePrimaryArtistOnlyEnabled": { "@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled" "description": "Subtitle when primary artist only is enabled"
}, },
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name", "downloadUsePrimaryArtistOnlyDisabled": "Nama lengkap artis digunakan untuk nama folder",
"@downloadUsePrimaryArtistOnlyDisabled": { "@downloadUsePrimaryArtistOnlyDisabled": {
"description": "Subtitle when primary artist only is disabled" "description": "Subtitle when primary artist only is disabled"
}, },
@@ -1833,27 +1857,27 @@
"@queueClearAllMessage": { "@queueClearAllMessage": {
"description": "Clear queue confirmation" "description": "Clear queue confirmation"
}, },
"settingsAutoExportFailed": "Auto-export failed downloads", "settingsAutoExportFailed": "Unduhan yang gagal diekspor otomatis",
"@settingsAutoExportFailed": { "@settingsAutoExportFailed": {
"description": "Setting toggle for auto-export" "description": "Setting toggle for auto-export"
}, },
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically", "settingsAutoExportFailedSubtitle": "Simpan unduhan yang gagal ke file TXT secara otomatis",
"@settingsAutoExportFailedSubtitle": { "@settingsAutoExportFailedSubtitle": {
"description": "Subtitle for auto-export setting" "description": "Subtitle for auto-export setting"
}, },
"settingsDownloadNetwork": "Download Network", "settingsDownloadNetwork": "Jaringan Unduhan",
"@settingsDownloadNetwork": { "@settingsDownloadNetwork": {
"description": "Setting for network type preference" "description": "Setting for network type preference"
}, },
"settingsDownloadNetworkAny": "WiFi + Mobile Data", "settingsDownloadNetworkAny": "WiFi + Data Seluler",
"@settingsDownloadNetworkAny": { "@settingsDownloadNetworkAny": {
"description": "Network option - use any connection" "description": "Network option - use any connection"
}, },
"settingsDownloadNetworkWifiOnly": "WiFi Only", "settingsDownloadNetworkWifiOnly": "Hanya WiFi",
"@settingsDownloadNetworkWifiOnly": { "@settingsDownloadNetworkWifiOnly": {
"description": "Network option - only use WiFi" "description": "Network option - only use WiFi"
}, },
"settingsDownloadNetworkSubtitle": "Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.", "settingsDownloadNetworkSubtitle": "Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.",
"@settingsDownloadNetworkSubtitle": { "@settingsDownloadNetworkSubtitle": {
"description": "Subtitle explaining network preference" "description": "Subtitle explaining network preference"
}, },
@@ -1889,11 +1913,11 @@
"@albumFolderYearAlbumSubtitle": { "@albumFolderYearAlbumSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumSingles": "Artist / Album + Singles", "albumFolderArtistAlbumSingles": "Artis / Album + Singel",
"@albumFolderArtistAlbumSingles": { "@albumFolderArtistAlbumSingles": {
"description": "Album folder option with singles inside artist" "description": "Album folder option with singles inside artist"
}, },
"albumFolderArtistAlbumSinglesSubtitle": "Artist/Album/ and Artist/Singles/", "albumFolderArtistAlbumSinglesSubtitle": "Artis/Album/ dan Artis/Single/",
"@albumFolderArtistAlbumSinglesSubtitle": { "@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
@@ -1962,19 +1986,19 @@
"@recentTypeSong": { "@recentTypeSong": {
"description": "Recent access item type - song/track" "description": "Recent access item type - song/track"
}, },
"recentTypePlaylist": "Playlist", "recentTypePlaylist": "Daftar putar",
"@recentTypePlaylist": { "@recentTypePlaylist": {
"description": "Recent access item type - playlist" "description": "Recent access item type - playlist"
}, },
"recentEmpty": "No recent items yet", "recentEmpty": "Belum ada item terbaru",
"@recentEmpty": { "@recentEmpty": {
"description": "Empty state text for recent access list" "description": "Empty state text for recent access list"
}, },
"recentShowAllDownloads": "Show All Downloads", "recentShowAllDownloads": "Tampilkan Semua Unduhan",
"@recentShowAllDownloads": { "@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access" "description": "Button label to unhide hidden downloads in recent access"
}, },
"recentPlaylistInfo": "Playlist: {name}", "recentPlaylistInfo": "Daftar Putar: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access", "description": "Snackbar message when tapping playlist in recent access",
"placeholders": { "placeholders": {
@@ -1984,7 +2008,7 @@
} }
} }
}, },
"discographyDownload": "Download Discography", "discographyDownload": "Unduh Diskografi",
"@discographyDownload": { "@discographyDownload": {
"description": "Button - download artist discography" "description": "Button - download artist discography"
}, },
@@ -2383,47 +2407,47 @@
} }
} }
}, },
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!", "tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
"@tutorialWelcomeTitle": { "@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title" "description": "Tutorial welcome page title"
}, },
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.", "tutorialWelcomeDesc": "Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
"@tutorialWelcomeDesc": { "@tutorialWelcomeDesc": {
"description": "Tutorial welcome page description" "description": "Tutorial welcome page description"
}, },
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL", "tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding", "tutorialWelcomeTip3": "Penyematan metadata, sampul album, dan lirik secara otomatis",
"@tutorialWelcomeTip3": { "@tutorialWelcomeTip3": {
"description": "Tutorial welcome tip 3" "description": "Tutorial welcome tip 3"
}, },
"tutorialSearchTitle": "Finding Music", "tutorialSearchTitle": "Menemukan Musik",
"@tutorialSearchTitle": { "@tutorialSearchTitle": {
"description": "Tutorial search page title" "description": "Tutorial search page title"
}, },
"tutorialSearchDesc": "There are two easy ways to find music you want to download.", "tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
"@tutorialSearchDesc": { "@tutorialSearchDesc": {
"description": "Tutorial search page description" "description": "Tutorial search page description"
}, },
"tutorialDownloadTitle": "Downloading Music", "tutorialDownloadTitle": "Mengunduh Musik",
"@tutorialDownloadTitle": { "@tutorialDownloadTitle": {
"description": "Tutorial download page title" "description": "Tutorial download page title"
}, },
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.", "tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.",
"@tutorialDownloadDesc": { "@tutorialDownloadDesc": {
"description": "Tutorial download page description" "description": "Tutorial download page description"
}, },
"tutorialLibraryTitle": "Your Library", "tutorialLibraryTitle": "Perpustakaan Anda",
"@tutorialLibraryTitle": { "@tutorialLibraryTitle": {
"description": "Tutorial library page title" "description": "Tutorial library page title"
}, },
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.", "tutorialLibraryDesc": "Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.",
"@tutorialLibraryDesc": { "@tutorialLibraryDesc": {
"description": "Tutorial library page description" "description": "Tutorial library page description"
}, },
@@ -2877,6 +2901,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
+116 -12
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "{item} を読み込めません: 拡張ソースがありません", "errorMissingExtensionSource": "{item} を読み込めません: 拡張ソースがありません",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -991,7 +1003,7 @@
"@filenameFormat": { "@filenameFormat": {
"description": "Setting title - filename pattern" "description": "Setting title - filename pattern"
}, },
"filenameShowAdvancedTags": "Show advanced tags", "filenameShowAdvancedTags": "高度なタグを表示",
"@filenameShowAdvancedTags": { "@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags" "description": "Toggle label for showing advanced filename tags"
}, },
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "アーティスト別", "folderOrganizationByArtist": "アーティスト別",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "内蔵", "providerBuiltIn": "内蔵",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "拡張", "providerExtension": "拡張",
"@providerExtension": { "@providerExtension": {
@@ -1471,7 +1491,7 @@
"@trackLyricsEmbedded": { "@trackLyricsEmbedded": {
"description": "Snackbar - lyrics saved to file" "description": "Snackbar - lyrics saved to file"
}, },
"trackInstrumental": "Instrumental track", "trackInstrumental": "インストゥルメンタルのトラック",
"@trackInstrumental": { "@trackInstrumental": {
"description": "Message when track is instrumental (no lyrics)" "description": "Message when track is instrumental (no lyrics)"
}, },
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "分割 CUE シート",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
@@ -2940,7 +3044,7 @@
"@collectionRemoveFromPlaylist": { "@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist" "description": "Tooltip for removing track from playlist"
}, },
"collectionRemoveFromFolder": "Remove from folder", "collectionRemoveFromFolder": "フォルダから削除",
"@collectionRemoveFromFolder": { "@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder" "description": "Tooltip for removing track from wishlist/loved folder"
}, },
@@ -2997,23 +3101,23 @@
"@trackOptionRemoveFromLoved": { "@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder" "description": "Bottom sheet action label - remove track from loved folder"
}, },
"trackOptionAddToWishlist": "Add to Wishlist", "trackOptionAddToWishlist": "ウィッシュリストに追加",
"@trackOptionAddToWishlist": { "@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist" "description": "Bottom sheet action label - add track to wishlist"
}, },
"trackOptionRemoveFromWishlist": "Remove from Wishlist", "trackOptionRemoveFromWishlist": "ウィッシュから削除",
"@trackOptionRemoveFromWishlist": { "@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist" "description": "Bottom sheet action label - remove track from wishlist"
}, },
"collectionPlaylistChangeCover": "Change cover image", "collectionPlaylistChangeCover": "カバー画像を変更",
"@collectionPlaylistChangeCover": { "@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist" "description": "Bottom sheet action to pick a custom cover image for a playlist"
}, },
"collectionPlaylistRemoveCover": "Remove cover image", "collectionPlaylistRemoveCover": "カバー画像を削除",
"@collectionPlaylistRemoveCover": { "@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist" "description": "Bottom sheet action to remove custom cover image from a playlist"
}, },
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}", "selectionShareCount": "{count} {count, plural, =1{個のトラック} other{個のトラック}}を共有",
"@selectionShareCount": { "@selectionShareCount": {
"description": "Share button text with count in selection mode", "description": "Share button text with count in selection mode",
"placeholders": { "placeholders": {
@@ -3039,7 +3143,7 @@
"@selectionConvertNoConvertible": { "@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion" "description": "Snackbar when no selected tracks support conversion"
}, },
"selectionBatchConvertConfirmTitle": "Batch Convert", "selectionBatchConvertConfirmTitle": "一括変換",
"@selectionBatchConvertConfirmTitle": { "@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion" "description": "Confirmation dialog title for batch conversion"
}, },
+107 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다", "errorMissingExtensionSource": "확장 소스가 누락되어, {item}(을)를 로드할 수 없습니다",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist", "folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
+111 -7
View File
@@ -194,11 +194,11 @@
"@optionsConcurrentDownloads": { "@optionsConcurrentDownloads": {
"description": "Number of parallel downloads" "description": "Number of parallel downloads"
}, },
"optionsConcurrentSequential": "Sequential (1 at a time)", "optionsConcurrentSequential": "Sequentiële (1 per keer)",
"@optionsConcurrentSequential": { "@optionsConcurrentSequential": {
"description": "Download one at a time" "description": "Download one at a time"
}, },
"optionsConcurrentParallel": "{count} parallel downloads", "optionsConcurrentParallel": "",
"@optionsConcurrentParallel": { "@optionsConcurrentParallel": {
"description": "Multiple parallel downloads", "description": "Multiple parallel downloads",
"placeholders": { "placeholders": {
@@ -207,7 +207,7 @@
} }
} }
}, },
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", "optionsConcurrentWarning": "Parallel downloaden kan leiden tot rate-limiting",
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
@@ -346,7 +346,7 @@
"@aboutContributors": { "@aboutContributors": {
"description": "Section for contributors" "description": "Section for contributors"
}, },
"aboutMobileDeveloper": "Mobile version developer", "aboutMobileDeveloper": "",
"@aboutMobileDeveloper": { "@aboutMobileDeveloper": {
"description": "Role description for mobile dev" "description": "Role description for mobile dev"
}, },
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source", "errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist", "folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
+409 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Não é possível carregar {item}: faltando a fonte da extensão", "errorMissingExtensionSource": "Não é possível carregar {item}: faltando a fonte da extensão",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -991,10 +1003,26 @@
"@filenameFormat": { "@filenameFormat": {
"description": "Setting title - filename pattern" "description": "Setting title - filename pattern"
}, },
"filenameShowAdvancedTags": "Show advanced tags",
"@filenameShowAdvancedTags": {
"description": "Toggle label for showing advanced filename tags"
},
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
"@filenameShowAdvancedTagsDescription": {
"description": "Description for advanced filename tag toggle"
},
"folderOrganizationNone": "Nenhuma organização", "folderOrganizationNone": "Nenhuma organização",
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "Por Artista", "folderOrganizationByArtist": "Por Artista",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1749,6 +1777,14 @@
"@youtubeQualityNote": { "@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality" "description": "Note for YouTube service explaining lossy-only quality"
}, },
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar", "downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
@@ -2198,6 +2234,15 @@
"@libraryAboutDescription": { "@libraryAboutDescription": {
"description": "Description of local library feature" "description": "Description of local library feature"
}, },
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
"@libraryTracksUnit": {
"description": "Unit label for tracks count (without the number itself)",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}", "libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": { "@libraryLastScanned": {
"description": "Last scan time display", "description": "Last scan time display",
@@ -2358,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2783,6 +2828,367 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create",
"@actionCreate": {
"description": "Generic action button - create"
},
"collectionFoldersTitle": "My folders",
"@collectionFoldersTitle": {
"description": "Library section title for custom folders"
},
"collectionWishlist": "Wishlist",
"@collectionWishlist": {
"description": "Custom folder for saved tracks to download later"
},
"collectionLoved": "Loved",
"@collectionLoved": {
"description": "Custom folder for favorite tracks"
},
"collectionPlaylists": "Playlists",
"@collectionPlaylists": {
"description": "Custom user playlists folder"
},
"collectionPlaylist": "Playlist",
"@collectionPlaylist": {
"description": "Single playlist label"
},
"collectionAddToPlaylist": "Add to playlist",
"@collectionAddToPlaylist": {
"description": "Action to add a track to user playlist"
},
"collectionCreatePlaylist": "Create playlist",
"@collectionCreatePlaylist": {
"description": "Action to create a new playlist"
},
"collectionNoPlaylistsYet": "No playlists yet",
"@collectionNoPlaylistsYet": {
"description": "Empty state title when user has no playlists"
},
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
"@collectionNoPlaylistsSubtitle": {
"description": "Empty state subtitle when user has no playlists"
},
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
"@collectionPlaylistTracks": {
"description": "Track count label for custom playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
"@collectionAddedToPlaylist": {
"description": "Snackbar after adding track to playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
"@collectionAlreadyInPlaylist": {
"description": "Snackbar when track already exists in playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistCreated": "Playlist created",
"@collectionPlaylistCreated": {
"description": "Snackbar after creating playlist"
},
"collectionPlaylistNameHint": "Playlist name",
"@collectionPlaylistNameHint": {
"description": "Hint text for playlist name input"
},
"collectionPlaylistNameRequired": "Playlist name is required",
"@collectionPlaylistNameRequired": {
"description": "Validation error for empty playlist name"
},
"collectionRenamePlaylist": "Rename playlist",
"@collectionRenamePlaylist": {
"description": "Action to rename playlist"
},
"collectionDeletePlaylist": "Delete playlist",
"@collectionDeletePlaylist": {
"description": "Action to delete playlist"
},
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
"@collectionDeletePlaylistMessage": {
"description": "Confirmation message for deleting playlist",
"placeholders": {
"playlistName": {
"type": "String"
}
}
},
"collectionPlaylistDeleted": "Playlist deleted",
"@collectionPlaylistDeleted": {
"description": "Snackbar after deleting playlist"
},
"collectionPlaylistRenamed": "Playlist renamed",
"@collectionPlaylistRenamed": {
"description": "Snackbar after renaming playlist"
},
"collectionWishlistEmptyTitle": "Wishlist is empty",
"@collectionWishlistEmptyTitle": {
"description": "Wishlist empty state title"
},
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
"@collectionWishlistEmptySubtitle": {
"description": "Wishlist empty state subtitle"
},
"collectionLovedEmptyTitle": "Loved folder is empty",
"@collectionLovedEmptyTitle": {
"description": "Loved empty state title"
},
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
"@collectionLovedEmptySubtitle": {
"description": "Loved empty state subtitle"
},
"collectionPlaylistEmptyTitle": "Playlist is empty",
"@collectionPlaylistEmptyTitle": {
"description": "Playlist empty state title"
},
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
"@collectionPlaylistEmptySubtitle": {
"description": "Playlist empty state subtitle"
},
"collectionRemoveFromPlaylist": "Remove from playlist",
"@collectionRemoveFromPlaylist": {
"description": "Tooltip for removing track from playlist"
},
"collectionRemoveFromFolder": "Remove from folder",
"@collectionRemoveFromFolder": {
"description": "Tooltip for removing track from wishlist/loved folder"
},
"collectionRemoved": "\"{trackName}\" removed",
"@collectionRemoved": {
"description": "Snackbar after removing a track from a collection",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
"@collectionAddedToLoved": {
"description": "Snackbar after adding track to loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
"@collectionRemovedFromLoved": {
"description": "Snackbar after removing track from loved folder",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
"@collectionAddedToWishlist": {
"description": "Snackbar after adding track to wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
"@collectionRemovedFromWishlist": {
"description": "Snackbar after removing track from wishlist",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"trackOptionAddToLoved": "Add to Loved",
"@trackOptionAddToLoved": {
"description": "Bottom sheet action label - add track to loved folder"
},
"trackOptionRemoveFromLoved": "Remove from Loved",
"@trackOptionRemoveFromLoved": {
"description": "Bottom sheet action label - remove track from loved folder"
},
"trackOptionAddToWishlist": "Add to Wishlist",
"@trackOptionAddToWishlist": {
"description": "Bottom sheet action label - add track to wishlist"
},
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
"@trackOptionRemoveFromWishlist": {
"description": "Bottom sheet action label - remove track from wishlist"
},
"collectionPlaylistChangeCover": "Change cover image",
"@collectionPlaylistChangeCover": {
"description": "Bottom sheet action to pick a custom cover image for a playlist"
},
"collectionPlaylistRemoveCover": "Remove cover image",
"@collectionPlaylistRemoveCover": {
"description": "Bottom sheet action to remove custom cover image from a playlist"
},
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
"@selectionShareCount": {
"description": "Share button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionShareNoFiles": "No shareable files found",
"@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk"
},
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
"@selectionConvertCount": {
"description": "Convert button text with count in selection mode",
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionConvertNoConvertible": "No convertible tracks selected",
"@selectionConvertNoConvertible": {
"description": "Snackbar when no selected tracks support conversion"
},
"selectionBatchConvertConfirmTitle": "Batch Convert",
"@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion"
},
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
},
"bitrate": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
"@selectionBatchConvertSuccess": {
"description": "Snackbar after batch conversion completes",
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"downloadedAlbumDownloadedCount": "{count} baixado(s)", "downloadedAlbumDownloadedCount": "{count} baixado(s)",
"@downloadedAlbumDownloadedCount": { "@downloadedAlbumDownloadedCount": {
"description": "Downloaded tracks count badge", "description": "Downloaded tracks count badge",
@@ -2800,4 +3206,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": { "@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming" "description": "Subtitle when Track Artist is used for folder naming"
} }
} }
+114 -10
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.", "aboutAppDescription": "Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Ссылка не распознана",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Невозможно загрузить {item}: отсутствует источник расширения", "errorMissingExtensionSource": "Невозможно загрузить {item}: отсутствует источник расширения",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "По плейлисту",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Отдельная папка для каждого плейлиста",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "По исполнителю", "folderOrganizationByArtist": "По исполнителю",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Встроенные", "providerBuiltIn": "Встроенные",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Расширение", "providerExtension": "Расширение",
"@providerExtension": { "@providerExtension": {
@@ -1789,7 +1809,7 @@
"@downloadUsePrimaryArtistOnly": { "@downloadUsePrimaryArtistOnly": {
"description": "Setting - strip featured artists from folder name" "description": "Setting - strip featured artists from folder name"
}, },
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)", "downloadUsePrimaryArtistOnlyEnabled": "Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)",
"@downloadUsePrimaryArtistOnlyEnabled": { "@downloadUsePrimaryArtistOnlyEnabled": {
"description": "Subtitle when primary artist only is enabled" "description": "Subtitle when primary artist only is enabled"
}, },
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music", "tutorialWelcomeTip2": "Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2487,7 +2507,7 @@
"@cleanupOrphanedDownloadsSubtitle": { "@cleanupOrphanedDownloadsSubtitle": {
"description": "Subtitle for orphaned cleanup button" "description": "Subtitle for orphaned cleanup button"
}, },
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history", "cleanupOrphanedDownloadsResult": "Удалено {count} утерянных записей из истории",
"@cleanupOrphanedDownloadsResult": { "@cleanupOrphanedDownloadsResult": {
"description": "Snackbar after orphan cleanup", "description": "Snackbar after orphan cleanup",
"placeholders": { "placeholders": {
@@ -2525,7 +2545,7 @@
"@cacheSectionStorage": { "@cacheSectionStorage": {
"description": "Section header for cache entries" "description": "Section header for cache entries"
}, },
"cacheSectionMaintenance": "Maintenance", "cacheSectionMaintenance": "Обслуживание",
"@cacheSectionMaintenance": { "@cacheSectionMaintenance": {
"description": "Section header for cleanup actions" "description": "Section header for cleanup actions"
}, },
@@ -2577,7 +2597,7 @@
"@cacheTrackLookupDesc": { "@cacheTrackLookupDesc": {
"description": "Description of what track lookup cache contains" "description": "Description of what track lookup cache contains"
}, },
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.", "cacheCleanupUnusedDesc": "Удалить записи из истории загрузок и библиотеки, которые остались без файлов.",
"@cacheCleanupUnusedDesc": { "@cacheCleanupUnusedDesc": {
"description": "Description of what cleanup unused data does" "description": "Description of what cleanup unused data does"
}, },
@@ -2653,7 +2673,7 @@
"@cacheCleanupUnused": { "@cacheCleanupUnused": {
"description": "Action title for cleaning unused entries" "description": "Action title for cleaning unused entries"
}, },
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries", "cacheCleanupUnusedSubtitle": "Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке",
"@cacheCleanupUnusedSubtitle": { "@cacheCleanupUnusedSubtitle": {
"description": "Subtitle for cleanup unused data action" "description": "Subtitle for cleanup unused data action"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Разделить CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Разделить файл CUE+FLAC на отдельные треки",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Альбом: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Артист: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} треков",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Разделенный CUE-альбом",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Разбить \"{album}\" на {count} отдельных FLAC-файлов?",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Разделение CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Успешно разделено на {count} треков",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "Разделение CUE не удалось",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Аудиофайл для этого CUE sheet не найден",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Разделить на Треки",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Создать", "actionCreate": "Создать",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
@@ -3022,7 +3126,7 @@
} }
} }
}, },
"selectionShareNoFiles": "No shareable files found", "selectionShareNoFiles": "Файлы, доступные для совместного доступа, не найдены",
"@selectionShareNoFiles": { "@selectionShareNoFiles": {
"description": "Snackbar when no selected files exist on disk" "description": "Snackbar when no selected files exist on disk"
}, },
@@ -3043,7 +3147,7 @@
"@selectionBatchConvertConfirmTitle": { "@selectionBatchConvertConfirmTitle": {
"description": "Confirmation dialog title for batch conversion" "description": "Confirmation dialog title for batch conversion"
}, },
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.", "selectionBatchConvertConfirmMessage": "Преобразовать {count} {count, plural, =1{track} other{tracks}} в {format} с {bitrate}?",
"@selectionBatchConvertConfirmMessage": { "@selectionBatchConvertConfirmMessage": {
"description": "Confirmation dialog message for batch conversion", "description": "Confirmation dialog message for batch conversion",
"placeholders": { "placeholders": {
+544 -138
View File
File diff suppressed because it is too large Load Diff
+203 -99
View File
@@ -5,143 +5,143 @@
"@appName": { "@appName": {
"description": "App name - DO NOT TRANSLATE" "description": "App name - DO NOT TRANSLATE"
}, },
"navHome": "Home", "navHome": "主页",
"@navHome": { "@navHome": {
"description": "Bottom navigation - Home tab" "description": "Bottom navigation - Home tab"
}, },
"navLibrary": "Library", "navLibrary": "乐库",
"@navLibrary": { "@navLibrary": {
"description": "Bottom navigation - Library tab" "description": "Bottom navigation - Library tab"
}, },
"navSettings": "Settings", "navSettings": "设置",
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
"navStore": "Store", "navStore": "商店",
"@navStore": { "@navStore": {
"description": "Bottom navigation - Extension store tab" "description": "Bottom navigation - Extension store tab"
}, },
"homeTitle": "Home", "homeTitle": "主页",
"@homeTitle": { "@homeTitle": {
"description": "Home screen title" "description": "Home screen title"
}, },
"homeSubtitle": "Paste a Spotify link or search by name", "homeSubtitle": "粘贴 Spotify 链接或按名称搜索",
"@homeSubtitle": { "@homeSubtitle": {
"description": "Subtitle shown below search box" "description": "Subtitle shown below search box"
}, },
"homeSupports": "Supports: Track, Album, Playlist, Artist URLs", "homeSupports": "支持:歌曲、专辑、播放列表、艺人网址",
"@homeSupports": { "@homeSupports": {
"description": "Info text about supported URL types" "description": "Info text about supported URL types"
}, },
"homeRecent": "Recent", "homeRecent": "最近",
"@homeRecent": { "@homeRecent": {
"description": "Section header for recent searches" "description": "Section header for recent searches"
}, },
"historyFilterAll": "All", "historyFilterAll": "全部",
"@historyFilterAll": { "@historyFilterAll": {
"description": "Filter chip - show all items" "description": "Filter chip - show all items"
}, },
"historyFilterAlbums": "Albums", "historyFilterAlbums": "专辑",
"@historyFilterAlbums": { "@historyFilterAlbums": {
"description": "Filter chip - show albums only" "description": "Filter chip - show albums only"
}, },
"historyFilterSingles": "Singles", "historyFilterSingles": "单曲",
"@historyFilterSingles": { "@historyFilterSingles": {
"description": "Filter chip - show singles only" "description": "Filter chip - show singles only"
}, },
"historySearchHint": "Search history...", "historySearchHint": "搜索历史……",
"@historySearchHint": { "@historySearchHint": {
"description": "Search bar placeholder in history" "description": "Search bar placeholder in history"
}, },
"settingsTitle": "Settings", "settingsTitle": "设置",
"@settingsTitle": { "@settingsTitle": {
"description": "Settings screen title" "description": "Settings screen title"
}, },
"settingsDownload": "Download", "settingsDownload": "下载",
"@settingsDownload": { "@settingsDownload": {
"description": "Settings section - download options" "description": "Settings section - download options"
}, },
"settingsAppearance": "Appearance", "settingsAppearance": "外观",
"@settingsAppearance": { "@settingsAppearance": {
"description": "Settings section - visual customization" "description": "Settings section - visual customization"
}, },
"settingsOptions": "Options", "settingsOptions": "选项",
"@settingsOptions": { "@settingsOptions": {
"description": "Settings section - app options" "description": "Settings section - app options"
}, },
"settingsExtensions": "Extensions", "settingsExtensions": "扩展",
"@settingsExtensions": { "@settingsExtensions": {
"description": "Settings section - extension management" "description": "Settings section - extension management"
}, },
"settingsAbout": "About", "settingsAbout": "关于",
"@settingsAbout": { "@settingsAbout": {
"description": "Settings section - app info" "description": "Settings section - app info"
}, },
"downloadTitle": "Download", "downloadTitle": "下载",
"@downloadTitle": { "@downloadTitle": {
"description": "Download settings page title" "description": "Download settings page title"
}, },
"downloadAskQualitySubtitle": "Show quality picker for each download", "downloadAskQualitySubtitle": "为每次下载显示质量选择器",
"@downloadAskQualitySubtitle": { "@downloadAskQualitySubtitle": {
"description": "Subtitle for ask quality toggle" "description": "Subtitle for ask quality toggle"
}, },
"downloadFilenameFormat": "Filename Format", "downloadFilenameFormat": "文件名格式",
"@downloadFilenameFormat": { "@downloadFilenameFormat": {
"description": "Setting for output filename pattern" "description": "Setting for output filename pattern"
}, },
"downloadFolderOrganization": "Folder Organization", "downloadFolderOrganization": "文件夹结构",
"@downloadFolderOrganization": { "@downloadFolderOrganization": {
"description": "Setting for folder structure" "description": "Setting for folder structure"
}, },
"appearanceTitle": "Appearance", "appearanceTitle": "外观",
"@appearanceTitle": { "@appearanceTitle": {
"description": "Appearance settings page title" "description": "Appearance settings page title"
}, },
"appearanceThemeSystem": "System", "appearanceThemeSystem": "系统",
"@appearanceThemeSystem": { "@appearanceThemeSystem": {
"description": "Follow system theme" "description": "Follow system theme"
}, },
"appearanceThemeLight": "Light", "appearanceThemeLight": "浅色",
"@appearanceThemeLight": { "@appearanceThemeLight": {
"description": "Light theme" "description": "Light theme"
}, },
"appearanceThemeDark": "Dark", "appearanceThemeDark": "深色",
"@appearanceThemeDark": { "@appearanceThemeDark": {
"description": "Dark theme" "description": "Dark theme"
}, },
"appearanceDynamicColor": "Dynamic Color", "appearanceDynamicColor": "动态色彩",
"@appearanceDynamicColor": { "@appearanceDynamicColor": {
"description": "Material You dynamic colors" "description": "Material You dynamic colors"
}, },
"appearanceDynamicColorSubtitle": "Use colors from your wallpaper", "appearanceDynamicColorSubtitle": "使用壁纸的颜色",
"@appearanceDynamicColorSubtitle": { "@appearanceDynamicColorSubtitle": {
"description": "Subtitle for dynamic color" "description": "Subtitle for dynamic color"
}, },
"appearanceHistoryView": "History View", "appearanceHistoryView": "历史记录",
"@appearanceHistoryView": { "@appearanceHistoryView": {
"description": "Layout style for history" "description": "Layout style for history"
}, },
"appearanceHistoryViewList": "List", "appearanceHistoryViewList": "列表",
"@appearanceHistoryViewList": { "@appearanceHistoryViewList": {
"description": "List layout option" "description": "List layout option"
}, },
"appearanceHistoryViewGrid": "Grid", "appearanceHistoryViewGrid": "网格",
"@appearanceHistoryViewGrid": { "@appearanceHistoryViewGrid": {
"description": "Grid layout option" "description": "Grid layout option"
}, },
"optionsTitle": "Options", "optionsTitle": "选项",
"@optionsTitle": { "@optionsTitle": {
"description": "Options settings page title" "description": "Options settings page title"
}, },
"optionsPrimaryProvider": "Primary Provider", "optionsPrimaryProvider": "主要提供者",
"@optionsPrimaryProvider": { "@optionsPrimaryProvider": {
"description": "Main search provider setting" "description": "Main search provider setting"
}, },
"optionsPrimaryProviderSubtitle": "Service used when searching by track name.", "optionsPrimaryProviderSubtitle": "按歌曲名称搜索时使用的服务。",
"@optionsPrimaryProviderSubtitle": { "@optionsPrimaryProviderSubtitle": {
"description": "Subtitle for primary provider" "description": "Subtitle for primary provider"
}, },
"optionsUsingExtension": "Using extension: {extensionName}", "optionsUsingExtension": "使用扩展:{extensionName}",
"@optionsUsingExtension": { "@optionsUsingExtension": {
"description": "Shows active extension name", "description": "Shows active extension name",
"placeholders": { "placeholders": {
@@ -150,55 +150,55 @@
} }
} }
}, },
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", "optionsSwitchBack": "点击 Deezer Spotify 即可从扩展程序切换回来",
"@optionsSwitchBack": { "@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers" "description": "Hint to switch back to built-in providers"
}, },
"optionsAutoFallback": "Auto Fallback", "optionsAutoFallback": "自动回退",
"@optionsAutoFallback": { "@optionsAutoFallback": {
"description": "Auto-retry with other services" "description": "Auto-retry with other services"
}, },
"optionsAutoFallbackSubtitle": "Try other services if download fails", "optionsAutoFallbackSubtitle": "如果下载失败,请尝试其他服务",
"@optionsAutoFallbackSubtitle": { "@optionsAutoFallbackSubtitle": {
"description": "Subtitle for auto fallback" "description": "Subtitle for auto fallback"
}, },
"optionsUseExtensionProviders": "Use Extension Providers", "optionsUseExtensionProviders": "使用扩展提供商",
"@optionsUseExtensionProviders": { "@optionsUseExtensionProviders": {
"description": "Enable extension download providers" "description": "Enable extension download providers"
}, },
"optionsUseExtensionProvidersOn": "Extensions will be tried first", "optionsUseExtensionProvidersOn": "扩展会被最先尝试",
"@optionsUseExtensionProvidersOn": { "@optionsUseExtensionProvidersOn": {
"description": "Status when extension providers enabled" "description": "Status when extension providers enabled"
}, },
"optionsUseExtensionProvidersOff": "Using built-in providers only", "optionsUseExtensionProvidersOff": "仅使用内置提供商",
"@optionsUseExtensionProvidersOff": { "@optionsUseExtensionProvidersOff": {
"description": "Status when extension providers disabled" "description": "Status when extension providers disabled"
}, },
"optionsEmbedLyrics": "Embed Lyrics", "optionsEmbedLyrics": "内嵌歌词",
"@optionsEmbedLyrics": { "@optionsEmbedLyrics": {
"description": "Embed lyrics in audio files" "description": "Embed lyrics in audio files"
}, },
"optionsEmbedLyricsSubtitle": "Embed synced lyrics into FLAC files", "optionsEmbedLyricsSubtitle": "嵌入已同步歌词到 FLAC 文件",
"@optionsEmbedLyricsSubtitle": { "@optionsEmbedLyricsSubtitle": {
"description": "Subtitle for embed lyrics" "description": "Subtitle for embed lyrics"
}, },
"optionsMaxQualityCover": "Max Quality Cover", "optionsMaxQualityCover": "最高质量封面",
"@optionsMaxQualityCover": { "@optionsMaxQualityCover": {
"description": "Download highest quality album art" "description": "Download highest quality album art"
}, },
"optionsMaxQualityCoverSubtitle": "Download highest resolution cover art", "optionsMaxQualityCoverSubtitle": "下载最高分辨率封面",
"@optionsMaxQualityCoverSubtitle": { "@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover" "description": "Subtitle for max quality cover"
}, },
"optionsConcurrentDownloads": "Concurrent Downloads", "optionsConcurrentDownloads": "并行下载数",
"@optionsConcurrentDownloads": { "@optionsConcurrentDownloads": {
"description": "Number of parallel downloads" "description": "Number of parallel downloads"
}, },
"optionsConcurrentSequential": "Sequential (1 at a time)", "optionsConcurrentSequential": "按顺序下载(一次一首)",
"@optionsConcurrentSequential": { "@optionsConcurrentSequential": {
"description": "Download one at a time" "description": "Download one at a time"
}, },
"optionsConcurrentParallel": "{count} parallel downloads", "optionsConcurrentParallel": "同时下载 {count} ",
"@optionsConcurrentParallel": { "@optionsConcurrentParallel": {
"description": "Multiple parallel downloads", "description": "Multiple parallel downloads",
"placeholders": { "placeholders": {
@@ -207,67 +207,67 @@
} }
} }
}, },
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting", "optionsConcurrentWarning": "并行下载可能会触发速率限制",
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
"optionsExtensionStore": "Extension Store", "optionsExtensionStore": "扩展商店",
"@optionsExtensionStore": { "@optionsExtensionStore": {
"description": "Show/hide store tab" "description": "Show/hide store tab"
}, },
"optionsExtensionStoreSubtitle": "Show Store tab in navigation", "optionsExtensionStoreSubtitle": "在导航中显示商店标签",
"@optionsExtensionStoreSubtitle": { "@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle" "description": "Subtitle for extension store toggle"
}, },
"optionsCheckUpdates": "Check for Updates", "optionsCheckUpdates": "检查更新",
"@optionsCheckUpdates": { "@optionsCheckUpdates": {
"description": "Auto update check toggle" "description": "Auto update check toggle"
}, },
"optionsCheckUpdatesSubtitle": "Notify when new version is available", "optionsCheckUpdatesSubtitle": "当有新版本可用时通知",
"@optionsCheckUpdatesSubtitle": { "@optionsCheckUpdatesSubtitle": {
"description": "Subtitle for update check" "description": "Subtitle for update check"
}, },
"optionsUpdateChannel": "Update Channel", "optionsUpdateChannel": "更新频道",
"@optionsUpdateChannel": { "@optionsUpdateChannel": {
"description": "Stable vs preview releases" "description": "Stable vs preview releases"
}, },
"optionsUpdateChannelStable": "Stable releases only", "optionsUpdateChannelStable": "仅稳定版本",
"@optionsUpdateChannelStable": { "@optionsUpdateChannelStable": {
"description": "Only stable updates" "description": "Only stable updates"
}, },
"optionsUpdateChannelPreview": "Get preview releases", "optionsUpdateChannelPreview": "获取预览版本",
"@optionsUpdateChannelPreview": { "@optionsUpdateChannelPreview": {
"description": "Include beta/preview updates" "description": "Include beta/preview updates"
}, },
"optionsUpdateChannelWarning": "Preview may contain bugs or incomplete features", "optionsUpdateChannelWarning": "预览版本可能包含错误或者尚未完善的功能",
"@optionsUpdateChannelWarning": { "@optionsUpdateChannelWarning": {
"description": "Warning about preview channel" "description": "Warning about preview channel"
}, },
"optionsClearHistory": "Clear Download History", "optionsClearHistory": "清除下载历史记录",
"@optionsClearHistory": { "@optionsClearHistory": {
"description": "Delete all download history" "description": "Delete all download history"
}, },
"optionsClearHistorySubtitle": "Remove all downloaded tracks from history", "optionsClearHistorySubtitle": "从历史记录中清除所有已下载的曲目",
"@optionsClearHistorySubtitle": { "@optionsClearHistorySubtitle": {
"description": "Subtitle for clear history" "description": "Subtitle for clear history"
}, },
"optionsDetailedLogging": "Detailed Logging", "optionsDetailedLogging": "详细日志",
"@optionsDetailedLogging": { "@optionsDetailedLogging": {
"description": "Enable verbose logs for debugging" "description": "Enable verbose logs for debugging"
}, },
"optionsDetailedLoggingOn": "Detailed logs are being recorded", "optionsDetailedLoggingOn": "正在记录详细日志",
"@optionsDetailedLoggingOn": { "@optionsDetailedLoggingOn": {
"description": "Status when logging enabled" "description": "Status when logging enabled"
}, },
"optionsDetailedLoggingOff": "Enable for bug reports", "optionsDetailedLoggingOff": "为错误报告启用",
"@optionsDetailedLoggingOff": { "@optionsDetailedLoggingOff": {
"description": "Status when logging disabled" "description": "Status when logging disabled"
}, },
"optionsSpotifyCredentials": "Spotify Credentials", "optionsSpotifyCredentials": "Spotify 凭据",
"@optionsSpotifyCredentials": { "@optionsSpotifyCredentials": {
"description": "Spotify API credentials setting" "description": "Spotify API credentials setting"
}, },
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...", "optionsSpotifyCredentialsConfigured": "客户端 ID{clientId}……",
"@optionsSpotifyCredentialsConfigured": { "@optionsSpotifyCredentialsConfigured": {
"description": "Shows configured client ID preview", "description": "Shows configured client ID preview",
"placeholders": { "placeholders": {
@@ -276,27 +276,27 @@
} }
} }
}, },
"optionsSpotifyCredentialsRequired": "Required - tap to configure", "optionsSpotifyCredentialsRequired": "必填 - 点击配置",
"@optionsSpotifyCredentialsRequired": { "@optionsSpotifyCredentialsRequired": {
"description": "Prompt to set up credentials" "description": "Prompt to set up credentials"
}, },
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", "optionsSpotifyWarning": "Spotify 需要您自己的 API 凭据。在 developer.spotify.com 免费获取",
"@optionsSpotifyWarning": { "@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement" "description": "Info about Spotify API requirement"
}, },
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.", "optionsSpotifyDeprecationWarning": "Spotify 搜索将在 2026 年 3 月 3 日因 Spotify API 更改而被废弃。请切换到 Deezer",
"@optionsSpotifyDeprecationWarning": { "@optionsSpotifyDeprecationWarning": {
"description": "Warning about Spotify API deprecation" "description": "Warning about Spotify API deprecation"
}, },
"extensionsTitle": "Extensions", "extensionsTitle": "扩展",
"@extensionsTitle": { "@extensionsTitle": {
"description": "Extensions page title" "description": "Extensions page title"
}, },
"extensionsDisabled": "Disabled", "extensionsDisabled": "禁用",
"@extensionsDisabled": { "@extensionsDisabled": {
"description": "Extension status - inactive" "description": "Extension status - inactive"
}, },
"extensionsVersion": "Version {version}", "extensionsVersion": "版本 {version}",
"@extensionsVersion": { "@extensionsVersion": {
"description": "Extension version display", "description": "Extension version display",
"placeholders": { "placeholders": {
@@ -305,7 +305,7 @@
} }
} }
}, },
"extensionsAuthor": "by {author}", "extensionsAuthor": "来自 {author}",
"@extensionsAuthor": { "@extensionsAuthor": {
"description": "Extension author credit", "description": "Extension author credit",
"placeholders": { "placeholders": {
@@ -314,75 +314,75 @@
} }
} }
}, },
"extensionsUninstall": "Uninstall", "extensionsUninstall": "卸载",
"@extensionsUninstall": { "@extensionsUninstall": {
"description": "Uninstall extension button" "description": "Uninstall extension button"
}, },
"storeTitle": "Extension Store", "storeTitle": "扩展商店",
"@storeTitle": { "@storeTitle": {
"description": "Store screen title" "description": "Store screen title"
}, },
"storeSearch": "Search extensions...", "storeSearch": "搜索扩展……",
"@storeSearch": { "@storeSearch": {
"description": "Store search placeholder" "description": "Store search placeholder"
}, },
"storeInstall": "Install", "storeInstall": "安装",
"@storeInstall": { "@storeInstall": {
"description": "Install extension button" "description": "Install extension button"
}, },
"storeInstalled": "Installed", "storeInstalled": "已安装",
"@storeInstalled": { "@storeInstalled": {
"description": "Already installed badge" "description": "Already installed badge"
}, },
"storeUpdate": "Update", "storeUpdate": "更新",
"@storeUpdate": { "@storeUpdate": {
"description": "Update available button" "description": "Update available button"
}, },
"aboutTitle": "About", "aboutTitle": "关于",
"@aboutTitle": { "@aboutTitle": {
"description": "About page title" "description": "About page title"
}, },
"aboutContributors": "Contributors", "aboutContributors": "贡献者",
"@aboutContributors": { "@aboutContributors": {
"description": "Section for contributors" "description": "Section for contributors"
}, },
"aboutMobileDeveloper": "Mobile version developer", "aboutMobileDeveloper": "移动版本开发者",
"@aboutMobileDeveloper": { "@aboutMobileDeveloper": {
"description": "Role description for mobile dev" "description": "Role description for mobile dev"
}, },
"aboutOriginalCreator": "Creator of the original SpotiFLAC", "aboutOriginalCreator": "原 SpotiLDAC 创建者",
"@aboutOriginalCreator": { "@aboutOriginalCreator": {
"description": "Role description for original creator" "description": "Role description for original creator"
}, },
"aboutLogoArtist": "The talented artist who created our beautiful app logo!", "aboutLogoArtist": "有才华的艺术家创建了我们美丽的应用图标!",
"@aboutLogoArtist": { "@aboutLogoArtist": {
"description": "Role description for logo artist" "description": "Role description for logo artist"
}, },
"aboutTranslators": "Translators", "aboutTranslators": "译者",
"@aboutTranslators": { "@aboutTranslators": {
"description": "Section for translators" "description": "Section for translators"
}, },
"aboutSpecialThanks": "Special Thanks", "aboutSpecialThanks": "特别鸣谢",
"@aboutSpecialThanks": { "@aboutSpecialThanks": {
"description": "Section for special thanks" "description": "Section for special thanks"
}, },
"aboutLinks": "Links", "aboutLinks": "相关链接",
"@aboutLinks": { "@aboutLinks": {
"description": "Section for external links" "description": "Section for external links"
}, },
"aboutMobileSource": "Mobile source code", "aboutMobileSource": "移动版本源代码",
"@aboutMobileSource": { "@aboutMobileSource": {
"description": "Link to mobile GitHub repo" "description": "Link to mobile GitHub repo"
}, },
"aboutPCSource": "PC source code", "aboutPCSource": "桌面版本源代码",
"@aboutPCSource": { "@aboutPCSource": {
"description": "Link to PC GitHub repo" "description": "Link to PC GitHub repo"
}, },
"aboutReportIssue": "Report an issue", "aboutReportIssue": "报告一个问题",
"@aboutReportIssue": { "@aboutReportIssue": {
"description": "Link to report bugs" "description": "Link to report bugs"
}, },
"aboutReportIssueSubtitle": "Report any problems you encounter", "aboutReportIssueSubtitle": "报告您遇到的任何问题",
"@aboutReportIssueSubtitle": { "@aboutReportIssueSubtitle": {
"description": "Subtitle for report issue" "description": "Subtitle for report issue"
}, },
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -603,23 +603,23 @@
"@setupNotificationGranted": { "@setupNotificationGranted": {
"description": "Success message for notification permission" "description": "Success message for notification permission"
}, },
"setupNotificationEnable": "Enable Notifications", "setupNotificationEnable": "启用通知",
"@setupNotificationEnable": { "@setupNotificationEnable": {
"description": "Button to enable notifications" "description": "Button to enable notifications"
}, },
"setupFolderChoose": "Choose Download Folder", "setupFolderChoose": "选择下载文件夹",
"@setupFolderChoose": { "@setupFolderChoose": {
"description": "Button to choose folder" "description": "Button to choose folder"
}, },
"setupFolderDescription": "Select a folder where your downloaded music will be saved.", "setupFolderDescription": "选择保存您下载的音乐的文件夹。",
"@setupFolderDescription": { "@setupFolderDescription": {
"description": "Explanation for folder selection" "description": "Explanation for folder selection"
}, },
"setupSelectFolder": "Select Folder", "setupSelectFolder": "选择文件夹",
"@setupSelectFolder": { "@setupSelectFolder": {
"description": "Button to select folder" "description": "Button to select folder"
}, },
"setupEnableNotifications": "Enable Notifications", "setupEnableNotifications": "启用通知",
"@setupEnableNotifications": { "@setupEnableNotifications": {
"description": "Button to enable notifications" "description": "Button to enable notifications"
}, },
@@ -889,14 +889,26 @@
"@errorRateLimited": { "@errorRateLimited": {
"description": "Error title - too many requests" "description": "Error title - too many requests"
}, },
"errorRateLimitedMessage": "Too many requests. Please wait a moment before searching again.", "errorRateLimitedMessage": "请求过多。请等一会再搜索。",
"@errorRateLimitedMessage": { "@errorRateLimitedMessage": {
"description": "Error message - rate limit explanation" "description": "Error message - rate limit explanation"
}, },
"errorNoTracksFound": "No tracks found", "errorNoTracksFound": "未找到曲目",
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source", "errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist", "folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
+107 -3
View File
@@ -450,7 +450,7 @@
"@aboutSpotiSaverDesc": { "@aboutSpotiSaverDesc": {
"description": "Credit for SpotiSaver API" "description": "Credit for SpotiSaver API"
}, },
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
"@aboutAppDescription": { "@aboutAppDescription": {
"description": "App description in header card" "description": "App description in header card"
}, },
@@ -897,6 +897,18 @@
"@errorNoTracksFound": { "@errorNoTracksFound": {
"description": "Error - search returned no results" "description": "Error - search returned no results"
}, },
"errorUrlNotRecognized": "Link not recognized",
"@errorUrlNotRecognized": {
"description": "Error title - URL not handled by any extension or service"
},
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
"@errorUrlNotRecognizedMessage": {
"description": "Error message - URL not recognized explanation"
},
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
"@errorUrlFetchFailed": {
"description": "Error message - generic URL fetch failure"
},
"errorMissingExtensionSource": "Cannot load {item}: missing extension source", "errorMissingExtensionSource": "Cannot load {item}: missing extension source",
"@errorMissingExtensionSource": { "@errorMissingExtensionSource": {
"description": "Error - extension source not available", "description": "Error - extension source not available",
@@ -1003,6 +1015,14 @@
"@folderOrganizationNone": { "@folderOrganizationNone": {
"description": "Folder option - flat structure" "description": "Folder option - flat structure"
}, },
"folderOrganizationByPlaylist": "By Playlist",
"@folderOrganizationByPlaylist": {
"description": "Folder option - playlist folders"
},
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
"@folderOrganizationByPlaylistSubtitle": {
"description": "Subtitle for playlist folder option"
},
"folderOrganizationByArtist": "By Artist", "folderOrganizationByArtist": "By Artist",
"@folderOrganizationByArtist": { "@folderOrganizationByArtist": {
"description": "Folder option - artist folders" "description": "Folder option - artist folders"
@@ -1097,7 +1117,7 @@
}, },
"providerBuiltIn": "Built-in", "providerBuiltIn": "Built-in",
"@providerBuiltIn": { "@providerBuiltIn": {
"description": "Label for built-in providers (Tidal/Qobuz/Amazon)" "description": "Label for built-in providers (Tidal/Qobuz)"
}, },
"providerExtension": "Extension", "providerExtension": "Extension",
"@providerExtension": { "@providerExtension": {
@@ -2383,7 +2403,7 @@
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
@@ -2808,6 +2828,90 @@
"@trackConvertFailed": { "@trackConvertFailed": {
"description": "Snackbar when conversion fails" "description": "Snackbar when conversion fails"
}, },
"cueSplitTitle": "Split CUE Sheet",
"@cueSplitTitle": {
"description": "Title for CUE split bottom sheet"
},
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
"@cueSplitSubtitle": {
"description": "Subtitle for CUE split menu item"
},
"cueSplitAlbum": "Album: {album}",
"@cueSplitAlbum": {
"description": "Album name in CUE split sheet",
"placeholders": {
"album": {
"type": "String"
}
}
},
"cueSplitArtist": "Artist: {artist}",
"@cueSplitArtist": {
"description": "Artist name in CUE split sheet",
"placeholders": {
"artist": {
"type": "String"
}
}
},
"cueSplitTrackCount": "{count} tracks",
"@cueSplitTrackCount": {
"description": "Number of tracks in CUE sheet",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitConfirmTitle": "Split CUE Album",
"@cueSplitConfirmTitle": {
"description": "CUE split confirmation dialog title"
},
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
"@cueSplitConfirmMessage": {
"description": "CUE split confirmation dialog message",
"placeholders": {
"album": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
"@cueSplitSplitting": {
"description": "Snackbar while splitting CUE",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"cueSplitSuccess": "Split into {count} tracks successfully",
"@cueSplitSuccess": {
"description": "Snackbar after successful CUE split",
"placeholders": {
"count": {
"type": "int"
}
}
},
"cueSplitFailed": "CUE split failed",
"@cueSplitFailed": {
"description": "Snackbar when CUE split fails"
},
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
"@cueSplitNoAudioFile": {
"description": "Error when CUE audio file is missing"
},
"cueSplitButton": "Split into Tracks",
"@cueSplitButton": {
"description": "Button text to start CUE splitting"
},
"actionCreate": "Create", "actionCreate": "Create",
"@actionCreate": { "@actionCreate": {
"description": "Generic action button - create" "description": "Generic action button - create"
+6 -4
View File
@@ -222,10 +222,12 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
// All checks passed -- start an incremental scan. // All checks passed -- start an incremental scan.
final iosBookmark = settings.localLibraryBookmark; final iosBookmark = settings.localLibraryBookmark;
ref.read(localLibraryProvider.notifier).startScan( ref
settings.localLibraryPath, .read(localLibraryProvider.notifier)
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, .startScan(
); settings.localLibraryPath,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} }
Future<void> _initializeAppServices() async { Future<void> _initializeAppServices() async {
+17 -2
View File
@@ -20,6 +20,7 @@ class AppSettings {
final String updateChannel; final String updateChannel;
final bool hasSearchedBefore; final bool hasSearchedBefore;
final String folderOrganization; final String folderOrganization;
final bool createPlaylistFolder;
final bool useAlbumArtistForFolders; final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist; final bool filterContributingArtistsInAlbumArtist;
@@ -33,11 +34,14 @@ class AppSettings {
final bool enableLogging; final bool enableLogging;
final bool useExtensionProviders; final bool useExtensionProviders;
final String? searchProvider; final String? searchProvider;
final String? homeFeedProvider;
final bool separateSingles; final bool separateSingles;
final String albumFolderStructure; final String albumFolderStructure;
final bool showExtensionStore; final bool showExtensionStore;
final String locale; final String locale;
final String lyricsMode; final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int final int
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps) youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
final int final int
@@ -96,6 +100,7 @@ class AppSettings {
this.updateChannel = 'stable', this.updateChannel = 'stable',
this.hasSearchedBefore = false, this.hasSearchedBefore = false,
this.folderOrganization = 'none', this.folderOrganization = 'none',
this.createPlaylistFolder = false,
this.useAlbumArtistForFolders = true, this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false, this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false, this.filterContributingArtistsInAlbumArtist = false,
@@ -109,11 +114,13 @@ class AppSettings {
this.enableLogging = false, this.enableLogging = false,
this.useExtensionProviders = true, this.useExtensionProviders = true,
this.searchProvider, this.searchProvider,
this.homeFeedProvider,
this.separateSingles = false, this.separateSingles = false,
this.albumFolderStructure = 'artist_album', this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, this.showExtensionStore = true,
this.locale = 'system', this.locale = 'system',
this.lyricsMode = 'embed', this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256, this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320, this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false, this.useAllFilesAccess = false,
@@ -159,6 +166,7 @@ class AppSettings {
String? updateChannel, String? updateChannel,
bool? hasSearchedBefore, bool? hasSearchedBefore,
String? folderOrganization, String? folderOrganization,
bool? createPlaylistFolder,
bool? useAlbumArtistForFolders, bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly, bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist, bool? filterContributingArtistsInAlbumArtist,
@@ -173,11 +181,14 @@ class AppSettings {
bool? useExtensionProviders, bool? useExtensionProviders,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, bool clearSearchProvider = false,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles, bool? separateSingles,
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
String? locale, String? locale,
String? lyricsMode, String? lyricsMode,
String? tidalHighFormat,
int? youtubeOpusBitrate, int? youtubeOpusBitrate,
int? youtubeMp3Bitrate, int? youtubeMp3Bitrate,
bool? useAllFilesAccess, bool? useAllFilesAccess,
@@ -215,6 +226,7 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel, updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
useAlbumArtistForFolders: useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders, useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly, usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
@@ -236,11 +248,15 @@ class AppSettings {
searchProvider: clearSearchProvider searchProvider: clearSearchProvider
? null ? null
: (searchProvider ?? this.searchProvider), : (searchProvider ?? this.searchProvider),
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
separateSingles: separateSingles ?? this.separateSingles, separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale, locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode, lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
@@ -255,8 +271,7 @@ class AppSettings {
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark, localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
localLibraryShowDuplicates: localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryAutoScan: localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
localLibraryAutoScan ?? this.localLibraryAutoScan,
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsProviders: lyricsProviders ?? this.lyricsProviders,
lyricsIncludeTranslationNetease: lyricsIncludeTranslationNetease:
+6
View File
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable', updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true, useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false, usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist: filterContributingArtistsInAlbumArtist:
@@ -38,12 +39,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
enableLogging: json['enableLogging'] as bool? ?? false, enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?, searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false, separateSingles: json['separateSingles'] as bool? ?? false,
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,
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',
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
@@ -100,6 +103,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'updateChannel': instance.updateChannel, 'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore, 'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'createPlaylistFolder': instance.createPlaylistFolder,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly, 'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist': 'filterContributingArtistsInAlbumArtist':
@@ -114,11 +118,13 @@ Map<String, dynamic> _$AppSettingsToJson(
'enableLogging': instance.enableLogging, 'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders, 'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider, 'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles, 'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure, 'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale, 'locale': instance.locale,
'lyricsMode': instance.lyricsMode, 'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate, 'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess, 'useAllFilesAccess': instance.useAllFilesAccess,
+365 -113
View File
@@ -122,7 +122,7 @@ class DownloadHistoryItem {
artistName: json['artistName'] as String, artistName: json['artistName'] as String,
albumName: json['albumName'] as String, albumName: json['albumName'] as String,
albumArtist: normalizeOptionalString(json['albumArtist'] as String?), albumArtist: normalizeOptionalString(json['albumArtist'] as String?),
coverUrl: json['coverUrl'] as String?, coverUrl: normalizeCoverReference(json['coverUrl']?.toString()),
filePath: json['filePath'] as String, filePath: json['filePath'] as String,
storageMode: json['storageMode'] as String?, storageMode: json['storageMode'] as String?,
downloadTreeUri: json['downloadTreeUri'] as String?, downloadTreeUri: json['downloadTreeUri'] as String?,
@@ -176,7 +176,7 @@ class DownloadHistoryItem {
artistName: artistName ?? this.artistName, artistName: artistName ?? this.artistName,
albumName: albumName ?? this.albumName, albumName: albumName ?? this.albumName,
albumArtist: albumArtist ?? this.albumArtist, albumArtist: albumArtist ?? this.albumArtist,
coverUrl: coverUrl ?? this.coverUrl, coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl),
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
storageMode: storageMode ?? this.storageMode, storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
@@ -1651,12 +1651,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool createPlaylistFolder = false,
bool useAlbumArtistForFolders = true, bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false, bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false, bool filterContributingArtistsInAlbumArtist = false,
String? playlistName, String? playlistName,
}) async { }) async {
String baseDir = state.outputDir; String baseDir = state.outputDir;
if (createPlaylistFolder &&
folderOrganization != 'playlist' &&
playlistName != null &&
playlistName.isNotEmpty) {
final playlistFolder = _sanitizeFolderName(playlistName);
if (playlistFolder.isNotEmpty) {
baseDir = '$baseDir${Platform.pathSeparator}$playlistFolder';
await _ensureDirExists(baseDir, label: 'Playlist folder');
}
}
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders var folderArtist = useAlbumArtistForFolders
? normalizedAlbumArtist ?? track.artistName ? normalizedAlbumArtist ?? track.artistName
@@ -1809,11 +1820,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool createPlaylistFolder = false,
bool useAlbumArtistForFolders = true, bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false, bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false, bool filterContributingArtistsInAlbumArtist = false,
String? playlistName, String? playlistName,
}) async { }) async {
final playlistPrefix =
createPlaylistFolder &&
folderOrganization != 'playlist' &&
playlistName != null &&
playlistName.isNotEmpty
? _sanitizeFolderName(playlistName)
: '';
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders var folderArtist = useAlbumArtistForFolders
? normalizedAlbumArtist ?? track.artistName ? normalizedAlbumArtist ?? track.artistName
@@ -1833,34 +1852,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) { if (isSingle) {
return '$artistName/Singles'; return _joinRelativePath(playlistPrefix, '$artistName/Singles');
} }
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
return '$artistName/$albumName'; return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
} }
if (isSingle) { if (isSingle) {
return 'Singles'; return _joinRelativePath(playlistPrefix, 'Singles');
} }
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
final year = _extractYear(track.releaseDate); final year = _extractYear(track.releaseDate);
switch (albumFolderStructure) { switch (albumFolderStructure) {
case 'album_only': case 'album_only':
return 'Albums/$albumName'; return _joinRelativePath(playlistPrefix, 'Albums/$albumName');
case 'artist_year_album': case 'artist_year_album':
final yearAlbum = year != null ? '[$year] $albumName' : albumName; final yearAlbum = year != null ? '[$year] $albumName' : albumName;
return 'Albums/$artistName/$yearAlbum'; return _joinRelativePath(
playlistPrefix,
'Albums/$artistName/$yearAlbum',
);
case 'year_album': case 'year_album':
final yearAlbum = year != null ? '[$year] $albumName' : albumName; final yearAlbum = year != null ? '[$year] $albumName' : albumName;
return 'Albums/$yearAlbum'; return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum');
default: default:
return 'Albums/$artistName/$albumName'; return _joinRelativePath(
playlistPrefix,
'Albums/$artistName/$albumName',
);
} }
} }
if (folderOrganization == 'none') { if (folderOrganization == 'none') {
return ''; return playlistPrefix;
} }
switch (folderOrganization) { switch (folderOrganization) {
@@ -1870,18 +1895,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
return ''; return '';
case 'artist': case 'artist':
return _sanitizeFolderName(folderArtist); return _joinRelativePath(
playlistPrefix,
_sanitizeFolderName(folderArtist),
);
case 'album': case 'album':
return _sanitizeFolderName(track.albumName); return _joinRelativePath(
playlistPrefix,
_sanitizeFolderName(track.albumName),
);
case 'artist_album': case 'artist_album':
final artistName = _sanitizeFolderName(folderArtist); final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
return '$artistName/$albumName'; return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
default: default:
return ''; return playlistPrefix;
} }
} }
String _joinRelativePath(String prefix, String suffix) {
if (prefix.isEmpty) return suffix;
if (suffix.isEmpty) return prefix;
return '$prefix/$suffix';
}
String _determineOutputExt(String quality, String service) { String _determineOutputExt(String quality, String service) {
if (service.toLowerCase() == 'youtube') { if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) { if (quality.toLowerCase().contains('mp3')) {
@@ -1890,7 +1927,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '.opus'; return '.opus';
} }
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.flac'; // HIGH quality no longer available; fallback to FLAC return '.m4a';
} }
return '.flac'; return '.flac';
} }
@@ -2497,8 +2534,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendIsrc = normalizeOptionalString( final backendIsrc = normalizeOptionalString(
backendResult['isrc'] as String?, backendResult['isrc'] as String?,
); );
final backendCoverUrl = normalizeOptionalString( final backendCoverUrl = normalizeCoverReference(
backendResult['cover_url'] as String?, backendResult['cover_url']?.toString(),
); );
final backendAlbumArtist = normalizeOptionalString( final backendAlbumArtist = normalizeOptionalString(
backendResult['album_artist'] as String?, backendResult['album_artist'] as String?,
@@ -2554,7 +2591,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String? coverPath; String? coverPath;
var coverUrl = track.coverUrl; var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) { if (coverUrl != null && coverUrl.isNotEmpty) {
try { try {
if (settings.maxQualityCover) { if (settings.maxQualityCover) {
@@ -2740,7 +2777,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String? coverPath; String? coverPath;
var coverUrl = track.coverUrl; var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) { if (coverUrl != null && coverUrl.isNotEmpty) {
try { try {
if (settings.maxQualityCover) { if (settings.maxQualityCover) {
@@ -2908,7 +2945,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String? coverPath; String? coverPath;
var coverUrl = track.coverUrl; var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) { if (coverUrl != null && coverUrl.isNotEmpty) {
try { try {
if (settings.maxQualityCover) { if (settings.maxQualityCover) {
@@ -3547,6 +3584,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders, useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly, usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist: filterContributingArtistsInAlbumArtist:
@@ -3562,6 +3600,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders, useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly, usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist: filterContributingArtistsInAlbumArtist:
@@ -3603,6 +3642,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
e.hasDownloadProvider && e.hasDownloadProvider &&
e.id.toLowerCase() == item.service.toLowerCase(), e.id.toLowerCase() == item.service.toLowerCase(),
); );
final trackSource = (trackToDownload.source ?? '').trim().toLowerCase();
final shouldSkipExtensionSongLinkPrelookup =
trackSource.isNotEmpty &&
extensionState.extensions.any(
(e) =>
e.enabled &&
e.hasMetadataProvider &&
e.id.toLowerCase() == trackSource,
);
String? deezerTrackId = trackToDownload.deezerId; String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
@@ -3639,6 +3687,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Fallback: Use SongLink to convert Spotify ID to Deezer ID // Fallback: Use SongLink to convert Spotify ID to Deezer ID
if (!selectedExtensionDownloadProvider && if (!selectedExtensionDownloadProvider &&
deezerTrackId == null && deezerTrackId == null &&
!shouldSkipExtensionSongLinkPrelookup &&
trackToDownload.id.isNotEmpty && trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) { !trackToDownload.id.startsWith('extension:')) {
@@ -3702,7 +3751,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumArtist: trackToDownload.albumArtist, albumArtist: trackToDownload.albumArtist,
artistId: trackToDownload.artistId, artistId: trackToDownload.artistId,
albumId: trackToDownload.albumId, albumId: trackToDownload.albumId,
coverUrl: trackToDownload.coverUrl, coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
duration: trackToDownload.duration, duration: trackToDownload.duration,
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc ? deezerIsrc
@@ -3743,6 +3792,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d( _log.d(
'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}', 'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}',
); );
} else if (shouldSkipExtensionSongLinkPrelookup &&
deezerTrackId == null) {
_log.d(
'Skipping Flutter SongLink Deezer prelookup for extension-sourced track; backend metadata enrichment will resolve identifiers first',
);
} }
if (deezerTrackId != null && deezerTrackId.isNotEmpty) { if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
@@ -3905,10 +3959,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders, useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly, usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist: filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist, settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
); );
final fallbackResult = await runDownload( final fallbackResult = await runDownload(
useSaf: false, useSaf: false,
@@ -3985,6 +4041,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
item.service.toLowerCase(); item.service.toLowerCase();
final decryptionKey = final decryptionKey =
(result['decryption_key'] as String?)?.trim() ?? ''; (result['decryption_key'] as String?)?.trim() ?? '';
trackToDownload = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
_log.d(
'Track coverUrl after download result: ${trackToDownload.coverUrl}',
);
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...'); _log.i('Encrypted stream detected, decrypting via FFmpeg...');
@@ -4122,50 +4186,73 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final currentFilePath = filePath; final currentFilePath = filePath;
if (isContentUriPath && effectiveSafMode) { if (isContentUriPath && effectiveSafMode) {
_log.d('M4A file detected (SAF), converting to FLAC...'); if (quality == 'HIGH') {
final tempPath = await _copySafToTemp(currentFilePath); final tidalHighFormat = settings.tidalHighFormat;
if (tempPath != null) { _log.i(
String? flacPath; 'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
try { );
final length = await File(tempPath).length();
if (length < 1024) { final tempPath = await _copySafToTemp(currentFilePath);
_log.w('Temp M4A is too small (<1KB), skipping conversion'); if (tempPath != null) {
} else { String? convertedPath;
try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.downloading,
progress: 0.95, progress: 0.95,
); );
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath != null) { final format = tidalHighFormat.startsWith('opus')
_log.d('Converted to FLAC (temp): $flacPath'); ? 'opus'
_log.d('Embedding metadata and cover to converted FLAC...'); : 'mp3';
final finalTrack = _buildTrackForMetadataEmbedding( convertedPath = await FFmpegService.convertM4aToLossy(
trackToDownload, tempPath,
result, format: format,
resolvedAlbumArtist, bitrate: tidalHighFormat,
deleteOriginal: false,
);
if (convertedPath != null) {
_log.i(
'Successfully converted M4A to $format (temp): $convertedPath',
);
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
); );
final backendGenre = result['genre'] as String?; final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?; final backendCopyright = result['copyright'] as String?;
await _embedMetadataAndCover( if (format == 'mp3') {
flacPath, await _embedMetadataToMp3(
finalTrack, convertedPath,
genre: backendGenre ?? genre, trackToDownload,
label: backendLabel ?? label, genre: backendGenre ?? genre,
copyright: backendCopyright, label: backendLabel ?? label,
writeExternalLrc: false, copyright: backendCopyright,
); );
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
final newFileName = '${safBaseName ?? 'track'}.flac'; final newExt = format == 'opus' ? '.opus' : '.mp3';
final newFileName = '${safBaseName ?? 'track'}$newExt';
final newUri = await _writeTempToSaf( final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri, treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir, relativeDir: effectiveOutputDir,
fileName: newFileName, fileName: newFileName,
mimeType: _mimeTypeForExt('.flac'), mimeType: _mimeTypeForExt(newExt),
srcPath: flacPath, srcPath: convertedPath,
); );
if (newUri != null) { if (newUri != null) {
@@ -4174,60 +4261,57 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
filePath = newUri; filePath = newUri;
finalSafFileName = newFileName; finalSafFileName = newFileName;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
} else { } else {
_log.w('Failed to write FLAC to SAF, keeping M4A'); _log.w(
'Failed to write converted $format to SAF, keeping M4A',
);
actualQuality = 'AAC 320kbps';
} }
} else { } else {
_log.w('FFmpeg conversion returned null, keeping M4A file'); _log.w(
'M4A to $format conversion failed, keeping M4A file',
);
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('SAF M4A conversion failed: $e');
actualQuality = 'AAC 320kbps';
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
if (convertedPath != null) {
try {
await File(convertedPath).delete();
} catch (_) {}
} }
} }
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
// Clean up temp files
try {
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
} }
} } else {
} else { _log.d('M4A file detected (SAF), converting to FLAC...');
_log.d( final tempPath = await _copySafToTemp(currentFilePath);
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', if (tempPath != null) {
); String? flacPath;
try {
try { final length = await File(tempPath).length();
final file = File(currentFilePath); if (length < 1024) {
if (!await file.exists()) { _log.w('Temp M4A is too small (<1KB), skipping conversion');
_log.e('File does not exist at path: $filePath'); } else {
} else { updateItemStatus(
final length = await file.length(); item.id,
_log.i('File size before conversion: ${length / 1024} KB'); DownloadStatus.downloading,
progress: 0.95,
if (length < 1024) { );
_log.w( flacPath = await FFmpegService.convertM4aToFlac(tempPath);
'File is too small (<1KB), skipping conversion. Download might be corrupt.', if (flacPath != null) {
); _log.d('Converted to FLAC (temp): $flacPath');
} else { _log.d(
updateItemStatus( 'Embedding metadata and cover to converted FLAC...',
item.id, );
DownloadStatus.downloading,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
currentFilePath,
);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
_log.d('Embedding metadata and cover to converted FLAC...');
try {
final finalTrack = _buildTrackForMetadataEmbedding( final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload, trackToDownload,
result, result,
@@ -4238,32 +4322,200 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?; final backendCopyright = result['copyright'] as String?;
if (backendGenre != null ||
backendLabel != null ||
backendCopyright != null) {
_log.d(
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
);
}
await _embedMetadataAndCover( await _embedMetadataAndCover(
flacPath, flacPath,
finalTrack, finalTrack,
genre: backendGenre ?? genre, genre: backendGenre ?? genre,
label: backendLabel ?? label, label: backendLabel ?? label,
copyright: backendCopyright, copyright: backendCopyright,
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt('.flac'),
srcPath: flacPath,
);
if (newUri != null) {
if (newUri != currentFilePath) {
await _deleteSafFile(currentFilePath);
}
filePath = newUri;
finalSafFileName = newFileName;
} else {
_log.w('Failed to write FLAC to SAF, keeping M4A');
}
} else {
_log.w(
'FFmpeg conversion returned null, keeping M4A file',
); );
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
} }
} else { }
_log.w('FFmpeg conversion returned null, keeping M4A file'); } catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
} }
} }
} }
} catch (e) { }
_log.w('FFmpeg conversion process failed: $e, keeping M4A file'); } else {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality download, converting M4A to $tidalHighFormat...',
);
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final format = tidalHighFormat.startsWith('opus')
? 'opus'
: 'mp3';
final convertedPath = await FFmpegService.convertM4aToLossy(
currentFilePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
_log.i(
'Successfully converted M4A to $format: $convertedPath',
);
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (format == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} else {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try {
final file = File(currentFilePath);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
final length = await file.length();
_log.i('File size before conversion: ${length / 1024} KB');
if (length < 1024) {
_log.w(
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
);
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
currentFilePath,
);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
_log.d(
'Embedding metadata and cover to converted FLAC...',
);
try {
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (backendGenre != null ||
backendLabel != null ||
backendCopyright != null) {
_log.d(
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
);
}
await _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
}
} else {
_log.w(
'FFmpeg conversion returned null, keeping M4A file',
);
}
}
}
} catch (e) {
_log.w(
'FFmpeg conversion process failed: $e, keeping M4A file',
);
}
} }
} }
} else if (metadataEmbeddingEnabled && } else if (metadataEmbeddingEnabled &&
@@ -4715,7 +4967,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? backendAlbum ? backendAlbum
: trackToDownload.albumName, : trackToDownload.albumName,
albumArtist: historyAlbumArtist, albumArtist: historyAlbumArtist,
coverUrl: trackToDownload.coverUrl, coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath, filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app', storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: effectiveSafMode downloadTreeUri: effectiveSafMode
+29 -23
View File
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.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:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExploreProvider'); final _log = AppLogger('ExploreProvider');
@@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> _saveToCache(List<ExploreSection> sections) async { Future<void> _saveToCache(List<ExploreSection> sections) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final data = { final data = {'sections': sections.map((s) => s.toJson()).toList()};
'sections': sections.map((s) => s.toJson()).toList(),
};
await prefs.setString(_cacheKey, jsonEncode(data)); await prefs.setString(_cacheKey, jsonEncode(data));
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${sections.length} explore sections to cache'); _log.d('Saved ${sections.length} explore sections to cache');
@@ -216,16 +215,16 @@ class ExploreNotifier extends Notifier<ExploreState> {
/// Fetch home feed from spotify-web extension /// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async { Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch // If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh && if (!forceRefresh &&
state.hasContent && state.hasContent &&
state.lastFetched != null && state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) { DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed (fresh enough)'); _log.d('Using cached home feed (fresh enough)');
return; return;
} }
if (state.isLoading) { if (state.isLoading) {
_log.d('Home feed fetch already in progress'); _log.d('Home feed fetch already in progress');
return; return;
@@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier<ExploreState> {
try { try {
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}'); final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
_log.d(
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
);
Extension? targetExt; Extension? targetExt;
for (final extension in extState.extensions) { for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) { if (!extension.enabled || !extension.hasHomeFeed) {
continue; continue;
} }
// If user has a preference, use that
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
// Otherwise take the first available (fallback to spotify-web if found)
if (targetExt == null || extension.id == 'spotify-web') { if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension; targetExt = extension;
if (extension.id == 'spotify-web') { if (preferredId == null && extension.id == 'spotify-web') {
break; break;
} }
} }
} }
if (targetExt == null) { if (targetExt == null) {
_log.w('No extension with homeFeed capability found'); _log.w('No extension with homeFeed capability found');
state = state.copyWith( state = state.copyWith(
@@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
); );
return; return;
} }
_log.i('Fetching home feed from ${targetExt.id}...'); _log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
@@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('getExtensionHomeFeed success=$success'); _log.d('getExtensionHomeFeed success=$success');
if (!success) { if (!success) {
final error = result['error'] as String? ?? 'Unknown error'; final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith( state = state.copyWith(isLoading: false, error: error);
isLoading: false,
error: error,
);
return; return;
} }
@@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
.toList(); .toList();
_log.i('Fetched ${sections.length} sections'); _log.i('Fetched ${sections.length} sections');
if (sections.isNotEmpty && sections.first.items.isNotEmpty) { if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first; final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}'); _log.d(
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
);
} }
final localGreeting = _getLocalGreeting(); final localGreeting = _getLocalGreeting();
@@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_saveToCache(sections); _saveToCache(sections);
} catch (e, stack) { } catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack); _log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith( state = state.copyWith(isLoading: false, error: e.toString());
isLoading: false,
error: e.toString(),
);
} }
} }
@@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> refresh() => fetchHomeFeed(forceRefresh: true); Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
} }
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() { final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier(); return ExploreNotifier();
}); });
+16
View File
@@ -504,6 +504,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
Future<void> _cleanupExtensions({required String reason}) async { Future<void> _cleanupExtensions({required String reason}) async {
if (!PlatformBridge.supportsExtensionSystem) {
_cleanupInFlight = false;
return;
}
try { try {
await PlatformBridge.cleanupExtensions(); await PlatformBridge.cleanupExtensions();
_log.d('Extensions cleaned up ($reason)'); _log.d('Extensions cleaned up ($reason)');
@@ -519,6 +524,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
if (!PlatformBridge.supportsExtensionSystem) {
state = state.copyWith(
isInitialized: true,
isLoading: false,
extensions: const [],
error: null,
);
_log.i('Extension system disabled on this platform');
return;
}
try { try {
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
await loadExtensions(extensionsDir); await loadExtensions(extensionsDir);
+23 -4
View File
@@ -53,6 +53,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
void _syncLyricsSettingsToBackend() { void _syncLyricsSettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
_log.w('Failed to sync lyrics providers to backend: $e'); _log.w('Failed to sync lyrics providers to backend: $e');
}); });
@@ -68,6 +70,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
void _syncNetworkCompatibilitySettingsToBackend() { void _syncNetworkCompatibilitySettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
final compatibilityMode = state.networkCompatibilityMode; final compatibilityMode = state.networkCompatibilityMode;
PlatformBridge.setNetworkCompatibilityOptions( PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode, allowHttp: compatibilityMode,
@@ -117,10 +121,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
useCustomSpotifyCredentials: false, useCustomSpotifyCredentials: false,
); );
} }
// Migration 6: Tidal HIGH quality removed migrate to LOSSLESS
if (state.audioQuality == 'HIGH') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
state = state.copyWith(lastSeenVersion: AppInfo.version); state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings(); await _saveSettings();
@@ -375,6 +375,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setCreatePlaylistFolder(bool enabled) {
state = state.copyWith(createPlaylistFolder: enabled);
_saveSettings();
}
void setUseAlbumArtistForFolders(bool enabled) { void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled); state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings(); _saveSettings();
@@ -419,6 +424,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
} else {
state = state.copyWith(homeFeedProvider: provider);
}
_saveSettings();
}
void setEnableLogging(bool enabled) { void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled); state = state.copyWith(enableLogging: enabled);
_saveSettings(); _saveSettings();
@@ -450,6 +464,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
void setYoutubeOpusBitrate(int bitrate) { void setYoutubeOpusBitrate(int bitrate) {
final normalized = _normalizeYouTubeOpusBitrate(bitrate); final normalized = _normalizeYouTubeOpusBitrate(bitrate);
state = state.copyWith(youtubeOpusBitrate: normalized); state = state.copyWith(youtubeOpusBitrate: normalized);
+108 -51
View File
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.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:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -30,6 +31,8 @@ class TrackState {
searchExtensionId; // Extension ID used for current search results searchExtensionId; // Extension ID used for current search results
final String? final String?
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
final String?
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
const TrackState({ const TrackState({
this.tracks = const [], this.tracks = const [],
@@ -52,6 +55,7 @@ class TrackState {
this.isShowingRecentAccess = false, this.isShowingRecentAccess = false,
this.searchExtensionId, this.searchExtensionId,
this.selectedSearchFilter, this.selectedSearchFilter,
this.searchSource,
}); });
bool get hasContent => bool get hasContent =>
@@ -83,6 +87,8 @@ class TrackState {
String? searchExtensionId, String? searchExtensionId,
String? selectedSearchFilter, String? selectedSearchFilter,
bool clearSelectedSearchFilter = false, bool clearSelectedSearchFilter = false,
String? searchSource,
bool clearSearchSource = false,
}) { }) {
return TrackState( return TrackState(
tracks: tracks ?? this.tracks, tracks: tracks ?? this.tracks,
@@ -108,6 +114,9 @@ class TrackState {
selectedSearchFilter: clearSelectedSearchFilter selectedSearchFilter: clearSelectedSearchFilter
? null ? null
: (selectedSearchFilter ?? this.selectedSearchFilter), : (selectedSearchFilter ?? this.selectedSearchFilter),
searchSource: clearSearchSource
? null
: (searchSource ?? this.searchSource),
); );
} }
} }
@@ -278,7 +287,9 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: type == 'playlist' playlistName: type == 'playlist'
? result['name'] as String? ? result['name'] as String?
: null, : null,
coverUrl: result['cover_url'] as String?, coverUrl: normalizeCoverReference(
result['cover_url']?.toString(),
),
searchExtensionId: extensionId, searchExtensionId: extensionId,
); );
return; return;
@@ -305,10 +316,12 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistData['id'] as String?, artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?, artistName: artistData['name'] as String?,
coverUrl: coverUrl: normalizeRemoteHttpUrl(
artistData['image_url'] as String? ?? (artistData['image_url'] ?? artistData['images'])?.toString(),
artistData['images'] as String?, ),
headerImageUrl: artistData['header_image'] as String?, headerImageUrl: normalizeRemoteHttpUrl(
artistData['header_image']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?, monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums, artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null, artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
@@ -349,7 +362,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: id, albumId: id,
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@@ -363,7 +376,9 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
playlistName: playlistInfo['name'] as String?, playlistName: playlistInfo['name'] as String?,
coverUrl: playlistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(
playlistInfo['images']?.toString(),
),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'artist') { } else if (type == 'artist') {
@@ -377,7 +392,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@@ -414,7 +429,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: 'qobuz:$id', albumId: 'qobuz:$id',
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@@ -427,8 +442,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?; final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName = final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?; (playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images']) as String?; (playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@@ -447,7 +463,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@@ -484,7 +500,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: 'tidal:$id', albumId: 'tidal:$id',
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@@ -497,8 +513,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?; final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName = final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?; (playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images']) as String?; (playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@@ -517,7 +534,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@@ -572,7 +589,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: parsed['id'] as String?, albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@@ -584,8 +601,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?; final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName = final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?; (playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images']) as String?; (playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@@ -604,7 +622,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@@ -618,7 +636,11 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
Future<void> search(String query, {String? filterOverride}) async { Future<void> search(
String query, {
String? filterOverride,
String? builtInSearchProvider,
}) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
// Preserve selected filter during loading // Preserve selected filter during loading
@@ -640,39 +662,68 @@ class TrackNotifier extends Notifier<TrackState> {
final includeExtensions = final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions; settings.useExtensionProviders && hasActiveMetadataExtensions;
// Determine the effective search provider
final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i( _log.i(
'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', 'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
); );
Map<String, dynamic> results; Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = []; List<Map<String, dynamic>> metadataTrackResults = [];
try { // Only use metadata providers for Deezer search (default behavior)
_log.d('Calling metadata provider search API...'); if (effectiveProvider == 'deezer') {
metadataTrackResults = try {
await PlatformBridge.searchTracksWithMetadataProviders( _log.d('Calling metadata provider search API...');
query, metadataTrackResults =
limit: 20, await PlatformBridge.searchTracksWithMetadataProviders(
includeExtensions: includeExtensions, query,
); limit: 20,
_log.i( includeExtensions: includeExtensions,
'Metadata providers returned ${metadataTrackResults.length} tracks', );
); _log.i(
} catch (e) { 'Metadata providers returned ${metadataTrackResults.length} tracks',
_log.w( );
'Metadata provider search failed, falling back to Deezer tracks: $e', } catch (e) {
); _log.w(
'Metadata provider search failed, falling back to Deezer tracks: $e',
);
}
} }
_log.d('Calling Deezer search API...'); // Call the appropriate search API
results = await PlatformBridge.searchDeezerAll( switch (effectiveProvider) {
query, case 'tidal':
trackLimit: 20, _log.d('Calling Tidal search API...');
artistLimit: 2, results = await PlatformBridge.searchTidalAll(
filter: currentFilter, query,
); trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
case 'qobuz':
_log.d('Calling Qobuz search API...');
results = await PlatformBridge.searchQobuzAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
default:
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
break;
}
_log.i( _log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums', '$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
); );
if (!_isRequestValid(requestId)) { if (!_isRequestValid(requestId)) {
@@ -758,6 +809,8 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter, // Preserve filter in results selectedSearchFilter: currentFilter, // Preserve filter in results
searchSource:
effectiveProvider, // Track which service was used for search
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
@@ -943,7 +996,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
artistId: (data['artist_id'] ?? data['artistId'])?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(), albumId: data['album_id']?.toString(),
coverUrl: data['images'] as String?, coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@@ -974,7 +1027,9 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist']?.toString(), albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(), albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(), coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@@ -1019,7 +1074,9 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0, totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(), coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
albumType: data['album_type'] as String? ?? 'album', albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '', artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(), providerId: data['provider_id']?.toString(),
@@ -1030,7 +1087,7 @@ class TrackNotifier extends Notifier<TrackState> {
return SearchArtist( return SearchArtist(
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
imageUrl: data['images'] as String?, imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
followers: data['followers'] as int? ?? 0, followers: data['followers'] as int? ?? 0,
popularity: data['popularity'] as int? ?? 0, popularity: data['popularity'] as int? ?? 0,
); );
@@ -1041,7 +1098,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
artists: data['artists'] as String? ?? '', artists: data['artists'] as String? ?? '',
imageUrl: data['images'] as String?, imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0, totalTracks: data['total_tracks'] as int? ?? 0,
albumType: data['album_type'] as String? ?? 'album', albumType: data['album_type'] as String? ?? 'album',
@@ -1053,7 +1110,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
owner: data['owner'] as String? ?? '', owner: data['owner'] as String? ?? '',
imageUrl: data['images'] as String?, imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
totalTracks: data['total_tracks'] as int? ?? 0, totalTracks: data['total_tracks'] as int? ?? 0,
); );
} }
+24 -11
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart';
@@ -94,7 +95,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
.recordAlbumAccess( .recordAlbumAccess(
id: widget.albumId, id: widget.albumId,
name: widget.albumName, name: widget.albumName,
artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName, artistName:
widget.artistName ??
widget.tracks?.firstOrNull?.albumArtist ??
widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl, imageUrl: widget.coverUrl,
providerId: providerId, providerId: providerId,
); );
@@ -226,7 +230,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistId: artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId, albumId: data['album_id']?.toString() ?? widget.albumId,
coverUrl: data['images'] as String?, coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@@ -280,7 +284,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
) { ) {
final expandedHeight = _calculateExpandedHeight(context); final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? []; final tracks = _tracks ?? [];
final artistName = widget.artistName ?? final artistName =
widget.artistName ??
(tracks.isNotEmpty (tracks.isNotEmpty
? (tracks.first.albumArtist ?? tracks.first.artistName) ? (tracks.first.albumArtist ?? tracks.first.artistName)
: null); : null);
@@ -574,17 +579,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
// Skip already-downloaded tracks // Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider) ? ref.read(localLibraryProvider)
: null; : null;
final tracksToQueue = <Track>[]; final tracksToQueue = <Track>[];
int skippedCount = 0; int skippedCount = 0;
for (final track in tracks) { for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) || final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null; historyState.findByTrackAndArtist(track.name, track.artistName) !=
final isInLocal = localLibState?.existsInLibrary( null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc, isrc: track.isrc,
trackName: track.name, trackName: track.name,
artistName: track.artistName, artistName: track.artistName,
@@ -617,7 +626,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracksToQueue, service, qualityOverride: quality); .addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount); _showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}, },
); );
@@ -633,9 +646,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final message = skipped > 0 final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped) ? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added); : context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(message)), context,
); ).showSnackBar(SnackBar(content: Text(message)));
} }
Widget _buildLoveAllButton() { Widget _buildLoveAllButton() {
+15 -12
View File
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen; show ExtensionAlbumScreen;
@@ -297,8 +298,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.toList(); .toList();
} }
final topTracksList = final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) { if (topTracksList.isNotEmpty) {
topTracks = topTracksList topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>)) .map((t) => _parseTrack(t as Map<String, dynamic>))
@@ -399,8 +399,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
(data['artist_id'] ?? data['artistId'])?.toString() ?? (data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId, widget.artistId,
albumId: data['album_id']?.toString() ?? album?.id, albumId: data['album_id']?.toString() ?? album?.id,
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl) coverUrl: normalizeCoverReference(
?.toString(), (data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
),
isrc: data['isrc']?.toString(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@@ -414,18 +415,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) { ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
final totalTracksValue = data['total_tracks']; final totalTracksValue = data['total_tracks'];
final totalTracks = final totalTracks = totalTracksValue is int
totalTracksValue is int ? totalTracksValue
? totalTracksValue : int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
return ArtistAlbum( return ArtistAlbum(
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: (data['name'] ?? data['title'] ?? '').toString(), name: (data['name'] ?? data['title'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: totalTracks, totalTracks: totalTracks,
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art']) coverUrl: normalizeCoverReference(
?.toString(), (data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
),
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(), albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
artists: (data['artists'] ?? data['artist'] ?? widget.artistName) artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
.toString(), .toString(),
@@ -1359,8 +1360,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}, },
itemBuilder: (context, pageIndex) { itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * tracksPerPage; final startIndex = pageIndex * tracksPerPage;
final endIndex = final endIndex = (startIndex + tracksPerPage).clamp(
(startIndex + tracksPerPage).clamp(0, tracks.length); 0,
tracks.length,
);
final pageTracks = tracks.sublist(startIndex, endIndex); final pageTracks = tracks.sublist(startIndex, endIndex);
return Column( return Column(
+11 -16
View File
@@ -946,8 +946,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
String selectedFormat = formats.first; String selectedFormat = formats.first;
bool isLosslessTarget = bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate = String selectedBitrate = isLosslessTarget
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); ? '320k'
: (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -1009,8 +1010,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
isLosslessTarget = isLosslessTarget =
format == 'ALAC' || format == 'FLAC'; format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) { if (!isLosslessTarget) {
selectedBitrate = selectedBitrate = format == 'Opus'
format == 'Opus' ? '128k' : '320k'; ? '128k'
: '320k';
} }
}); });
} }
@@ -1055,11 +1057,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.trackConvertLosslessHint, context.l10n.trackConvertLosslessHint,
style: Theme.of( style: Theme.of(context).textTheme.bodySmall
context, ?.copyWith(color: colorScheme.primary),
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
), ),
], ],
), ),
@@ -1175,7 +1174,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0; int successCount = 0;
final total = selected.length; final total = selected.length;
final historyDb = HistoryDatabase.instance; final historyDb = HistoryDatabase.instance;
final newQuality = (targetFormat.toUpperCase() == 'ALAC' || final newQuality =
(targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC') targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless' ? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}'; : '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
@@ -1206,12 +1206,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
try { try {
final result = await PlatformBridge.readFileMetadata(item.filePath); final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) { if (result['error'] == null) {
result.forEach((key, value) { mergePlatformMetadataForTagEmbed(target: metadata, source: result);
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
} }
} catch (_) {} } catch (_) {}
await ensureLyricsMetadataForConversion( await ensureLyricsMetadataForConversion(
+99 -7
View File
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -489,6 +490,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (searchProvider == null || searchProvider.isEmpty) return false; if (searchProvider == null || searchProvider.isEmpty) return false;
// Built-in providers (tidal, qobuz) also support live search
if (_builtInSearchProviders.contains(searchProvider)) return true;
final extension = extState.extensions final extension = extState.extensions
.where((e) => e.id == searchProvider && e.enabled) .where((e) => e.id == searchProvider && e.enabled)
.firstOrNull; .firstOrNull;
@@ -546,6 +550,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
} }
/// Built-in search providers that are not extensions
static const _builtInSearchProviders = {'tidal', 'qobuz'};
Future<void> _performSearch(String query, {String? filterOverride}) async { Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
@@ -558,9 +565,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (_lastSearchQuery == searchKey) return; if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey; _lastSearchQuery = searchKey;
final isBuiltInProvider =
searchProvider != null &&
_builtInSearchProviders.contains(searchProvider);
final isExtensionEnabled = final isExtensionEnabled =
searchProvider != null && searchProvider != null &&
searchProvider.isNotEmpty && searchProvider.isNotEmpty &&
!isBuiltInProvider &&
extState.extensions.any((e) => e.id == searchProvider && e.enabled); extState.extensions.any((e) => e.id == searchProvider && e.enabled);
if (isExtensionEnabled) { if (isExtensionEnabled) {
@@ -571,10 +583,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
await ref await ref
.read(trackProvider.notifier) .read(trackProvider.notifier)
.customSearch(searchProvider, query, options: options); .customSearch(searchProvider, query, options: options);
} else if (isBuiltInProvider) {
// Use built-in Tidal or Qobuz search
await ref
.read(trackProvider.notifier)
.search(
query,
filterOverride: selectedFilter,
builtInSearchProvider: searchProvider,
);
} else { } else {
if (searchProvider != null && if (searchProvider != null &&
searchProvider.isNotEmpty && searchProvider.isNotEmpty &&
!isExtensionEnabled) { !isExtensionEnabled &&
!isBuiltInProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null); ref.read(settingsProvider.notifier).setSearchProvider(null);
} }
await ref await ref
@@ -718,6 +740,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackName: track.name, trackName: track.name,
artistName: track.artistName, artistName: track.artistName,
coverUrl: track.coverUrl, coverUrl: track.coverUrl,
recommendedService: trackState.searchSource,
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
@@ -2770,6 +2793,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
if (searchProvider != null && searchProvider.isNotEmpty) { if (searchProvider != null && searchProvider.isNotEmpty) {
// Check built-in providers first
if (searchProvider == 'tidal') {
return 'Search with Tidal...';
}
if (searchProvider == 'qobuz') {
return 'Search with Qobuz...';
}
final ext = extState.extensions final ext = extState.extensions
.where((e) => e.id == searchProvider) .where((e) => e.id == searchProvider)
.firstOrNull; .firstOrNull;
@@ -3004,6 +3035,11 @@ class _SearchProviderDropdown extends ConsumerWidget {
.firstOrNull; .firstOrNull;
} }
// Check if current provider is a built-in provider (tidal/qobuz)
const builtInProviders = {'tidal', 'qobuz'};
final isBuiltInProvider =
currentProvider != null && builtInProviders.contains(currentProvider);
IconData displayIcon = Icons.search; IconData displayIcon = Icons.search;
String? iconPath; String? iconPath;
if (currentExt != null) { if (currentExt != null) {
@@ -3011,10 +3047,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
if (currentExt.searchBehavior?.icon != null) { if (currentExt.searchBehavior?.icon != null) {
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
} }
} } else if (isBuiltInProvider) {
displayIcon = Icons.music_note;
if (searchProviders.isEmpty) {
return const Icon(Icons.search);
} }
return Padding( return Padding(
@@ -3081,6 +3115,62 @@ class _SearchProviderDropdown extends ConsumerWidget {
], ],
), ),
), ),
// Built-in Tidal search option
PopupMenuItem<String>(
value: 'tidal',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'tidal'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Tidal',
style: TextStyle(
fontWeight: currentProvider == 'tidal'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'tidal')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
// Built-in Qobuz search option
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'qobuz'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Qobuz',
style: TextStyle(
fontWeight: currentProvider == 'qobuz'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'qobuz')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
if (searchProviders.isNotEmpty) const PopupMenuDivider(), if (searchProviders.isNotEmpty) const PopupMenuDivider(),
...searchProviders.map( ...searchProviders.map(
(ext) => PopupMenuItem<String>( (ext) => PopupMenuItem<String>(
@@ -4217,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
artists: (data['artists'] ?? '').toString(), artists: (data['artists'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: data['total_tracks'] as int? ?? 0, totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['cover_url']?.toString(), coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
albumType: (data['album_type'] ?? 'album').toString(), albumType: (data['album_type'] ?? 'album').toString(),
providerId: (data['provider_id'] ?? widget.extensionId).toString(), providerId: (data['provider_id'] ?? widget.extensionId).toString(),
); );
@@ -4242,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
(data['artist_id'] ?? data['artistId'])?.toString() ?? (data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId, widget.artistId,
albumId: data['album_id']?.toString(), albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(), coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
+12 -6
View File
@@ -820,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final format = item.format?.toLowerCase(); final format = item.format?.toLowerCase();
final lowerPath = item.filePath.toLowerCase(); final lowerPath = item.filePath.toLowerCase();
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
final isM4A =
format == 'm4a' ||
format == 'aac' ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.aac');
final isOpus = final isOpus =
format == 'opus' || format == 'opus' ||
format == 'ogg' || format == 'ogg' ||
@@ -833,6 +838,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
coverPath: effectiveCoverPath, coverPath: effectiveCoverPath,
metadata: metadata, metadata: metadata,
); );
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isOpus) { } else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus( ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget, opusPath: ffmpegTarget,
@@ -1450,12 +1461,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
try { try {
final result = await PlatformBridge.readFileMetadata(item.filePath); final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) { if (result['error'] == null) {
result.forEach((key, value) { mergePlatformMetadataForTagEmbed(target: metadata, source: result);
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
} }
} catch (_) {} } catch (_) {}
await ensureLyricsMetadataForConversion( await ensureLyricsMetadataForConversion(
+21 -9
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -128,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
albumArtist: data['album_artist']?.toString(), albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(), albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(), coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@@ -532,7 +535,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
tooltip: context.l10n.tooltipAddToPlaylist, tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks.isEmpty onPressed: _tracks.isEmpty
? null ? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName), : () => showAddTracksToPlaylistSheet(
context,
ref,
_tracks,
playlistNamePrefill: widget.playlistName,
),
); );
} }
@@ -611,17 +619,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
// Skip already-downloaded tracks // Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider) ? ref.read(localLibraryProvider)
: null; : null;
final tracksToQueue = <Track>[]; final tracksToQueue = <Track>[];
int skippedCount = 0; int skippedCount = 0;
for (final track in tracks) { for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) || final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null; historyState.findByTrackAndArtist(track.name, track.artistName) !=
final isInLocal = localLibState?.existsInLibrary( null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc, isrc: track.isrc,
trackName: track.name, trackName: track.name,
artistName: track.artistName, artistName: track.artistName,
@@ -679,9 +691,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final message = skipped > 0 final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped) ? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added); : context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(message)), context,
); ).showSnackBar(SnackBar(content: Text(message)));
} }
} }
+14 -7
View File
@@ -4400,6 +4400,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final format = item.format?.toLowerCase(); final format = item.format?.toLowerCase();
final lowerPath = item.filePath.toLowerCase(); final lowerPath = item.filePath.toLowerCase();
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3'); final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
final isM4A =
format == 'm4a' ||
format == 'aac' ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.aac');
final isOpus = final isOpus =
format == 'opus' || format == 'opus' ||
format == 'ogg' || format == 'ogg' ||
@@ -4413,6 +4418,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
coverPath: effectiveCoverPath, coverPath: effectiveCoverPath,
metadata: metadata, metadata: metadata,
); );
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (isOpus) { } else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus( ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget, opusPath: ffmpegTarget,
@@ -5090,12 +5101,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
try { try {
final result = await PlatformBridge.readFileMetadata(item.filePath); final result = await PlatformBridge.readFileMetadata(item.filePath);
if (result['error'] == null) { if (result['error'] == null) {
result.forEach((key, value) { mergePlatformMetadataForTagEmbed(target: metadata, source: result);
if (key == 'error' || value == null) return;
final v = value.toString().trim();
if (v.isEmpty) return;
metadata[key.toUpperCase()] = v;
});
} }
} catch (_) {} } catch (_) {}
await ensureLyricsMetadataForConversion( await ensureLyricsMetadataForConversion(
@@ -5473,7 +5479,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
icon: Icons.download_for_offline_outlined, icon: Icons.download_for_offline_outlined,
label: label:
'${context.l10n.queueFlacAction} ($flacEligibleCount)', '${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems), onPressed: () =>
_queueSelectedLocalAsFlac(unifiedItems),
colorScheme: colorScheme, colorScheme: colorScheme,
), ),
), ),
+24
View File
@@ -511,6 +511,30 @@ class _TranslatorsSection extends StatelessWidget {
language: 'Japanese', language: 'Japanese',
flag: '🇯🇵', flag: '🇯🇵',
), ),
_Translator(
name: 'unkn0wn',
crowdinUsername: 'rdclvi',
language: 'Indonesian',
flag: '🇮🇩',
),
_Translator(
name: 'lunching1272',
crowdinUsername: 'lunching1272',
language: 'Chinese Simplified',
flag: '🇨🇳',
),
_Translator(
name: 'Сергей Ильченко',
crowdinUsername: 'Sega_Mostky',
language: 'Russian',
flag: '🇷🇺',
),
_Translator(
name: 'Girl-lass',
crowdinUsername: 'Girl-lass',
language: 'Chinese Simplified',
flag: '🇨🇳',
),
_Translator( _Translator(
name: 'Kaan', name: 'Kaan',
crowdinUsername: 'glai', crowdinUsername: 'glai',
+156 -14
View File
@@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>['McNuggets Jimmy', 'zcc09', 'micahRichie', 'a fan', 'CJBGR']; const donorNames = <String>[
'McNuggets Jimmy',
'zcc09',
'micahRichie',
'a fan',
'CJBGR',
];
// Match SettingsGroup color logic // Match SettingsGroup color logic
final cardColor = isDark final cardColor = isDark
@@ -480,31 +486,77 @@ int _cr(String v) {
} }
// Highlighted supporters (hashes of names). // Highlighted supporters (hashes of names).
const _cv = <int>{1211573191, 1003219236, 560908930}; const _cv = <int>{1211573191, 1003219236};
class _SupporterChip extends StatelessWidget { // Diamond tier supporters ($50+ donors).
const _dv = <int>{560908930};
enum _SupporterTier { normal, gold, diamond }
_SupporterTier _tierOf(String name) {
final h = _cr(name);
if (_dv.contains(h)) return _SupporterTier.diamond;
if (_cv.contains(h)) return _SupporterTier.gold;
return _SupporterTier.normal;
}
class _SupporterChip extends StatefulWidget {
final String name; final String name;
final ColorScheme colorScheme; final ColorScheme colorScheme;
const _SupporterChip({required this.name, required this.colorScheme}); const _SupporterChip({required this.name, required this.colorScheme});
@override
State<_SupporterChip> createState() => _SupporterChipState();
}
class _SupporterChipState extends State<_SupporterChip>
with SingleTickerProviderStateMixin {
late final _SupporterTier _tier;
AnimationController? _shimmerController;
@override
void initState() {
super.initState();
_tier = _tierOf(widget.name);
if (_tier == _SupporterTier.diamond) {
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2400),
)..repeat();
}
}
@override
void dispose() {
_shimmerController?.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final e = _cv.contains(_cr(name)); final isDark = Theme.of(context).brightness == Brightness.dark;
if (_tier == _SupporterTier.diamond) {
return _buildDiamondChip(isDark);
}
final isGold = _tier == _SupporterTier.gold;
const goldChipColor = Color(0xFFFFF8DC); const goldChipColor = Color(0xFFFFF8DC);
const goldAccentColor = Color(0xFFB8860B); const goldAccentColor = Color(0xFFB8860B);
const goldDarkChipColor = Color(0xFF3A3000); const goldDarkChipColor = Color(0xFF3A3000);
final chipColor = e ? goldChipColor : colorScheme.secondaryContainer; final chipColor = isGold
final accentColor = e ? goldAccentColor : colorScheme.primary; ? goldChipColor
final isDark = Theme.of(context).brightness == Brightness.dark; : widget.colorScheme.secondaryContainer;
final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor; final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
return Material( return Material(
color: effectiveChipColor, color: effectiveChipColor,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Container( child: Container(
decoration: e decoration: isGold
? BoxDecoration( ? BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
@@ -520,10 +572,12 @@ class _SupporterChip extends StatelessWidget {
CircleAvatar( CircleAvatar(
radius: 10, radius: 10,
backgroundColor: accentColor.withValues(alpha: 0.2), backgroundColor: accentColor.withValues(alpha: 0.2),
child: e child: isGold
? Icon(Icons.star_rounded, size: 12, color: accentColor) ? Icon(Icons.star_rounded, size: 12, color: accentColor)
: Text( : Text(
name.isNotEmpty ? name[0].toUpperCase() : '?', widget.name.isNotEmpty
? widget.name[0].toUpperCase()
: '?',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -533,10 +587,12 @@ class _SupporterChip extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
name, widget.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: e ? accentColor : colorScheme.onSecondaryContainer, color: isGold
fontWeight: e ? FontWeight.w600 : FontWeight.w500, ? accentColor
: widget.colorScheme.onSecondaryContainer,
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
), ),
), ),
], ],
@@ -544,6 +600,92 @@ class _SupporterChip extends StatelessWidget {
), ),
); );
} }
Widget _buildDiamondChip(bool isDark) {
const diamondLight = Color(0xFFE8F4FD);
const diamondDark = Color(0xFF0D2B3E);
const diamondAccent = Color(0xFF4FC3F7);
const diamondHighlight = Color(0xFFB3E5FC);
final chipBg = isDark ? diamondDark : diamondLight;
return AnimatedBuilder(
animation: _shimmerController!,
builder: (context, child) {
final t = _shimmerController!.value;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment(-2.0 + 4.0 * t, 0.0),
end: Alignment(-1.0 + 4.0 * t, 0.0),
colors: [
chipBg,
isDark
? diamondAccent.withValues(alpha: 0.18)
: diamondHighlight.withValues(alpha: 0.7),
chipBg,
],
stops: const [0.0, 0.5, 1.0],
),
border: Border.all(
color: diamondAccent.withValues(
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: diamondAccent.withValues(
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
),
blurRadius: 8,
spreadRadius: 0,
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
diamondAccent.withValues(alpha: 0.3),
diamondAccent.withValues(alpha: 0.15),
],
),
),
child: const Icon(
Icons.diamond_rounded,
size: 12,
color: diamondAccent,
),
),
const SizedBox(width: 8),
Text(
widget.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: isDark ? diamondHighlight : diamondAccent,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
},
);
}
} }
class _NoticeLine extends StatelessWidget { class _NoticeLine extends StatelessWidget {
+181 -24
View File
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
@@ -300,6 +301,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final topPadding = normalizedHeaderTopPadding(context); final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService); final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -407,8 +409,37 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onTap: () => ref onTap: () => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'), .setAudioQuality('HI_RES_LOSSLESS'),
showDivider: false, showDivider: isTidalService,
), ),
// Lossy 320kbps option (Tidal only) - downloads M4A AAC from server, converts to MP3/Opus
if (isTidalService)
_QualityOption(
title: context.l10n.downloadLossy320,
subtitle: _getTidalHighFormatLabel(
context,
settings.tidalHighFormat,
),
isSelected: settings.audioQuality == 'HIGH',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HIGH'),
showDivider: false,
),
if (isTidalService && settings.audioQuality == 'HIGH')
SettingsItem(
icon: Icons.tune,
title: context.l10n.downloadLossyFormat,
subtitle: _getTidalHighFormatLabel(
context,
settings.tidalHighFormat,
),
onTap: () => _showTidalHighFormatPicker(
context,
ref,
settings.tidalHighFormat,
),
showDivider: false,
),
], ],
if (!isBuiltInService) ...[ if (!isBuiltInService) ...[
Padding( Padding(
@@ -436,7 +467,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
], ],
SettingsItem( SettingsItem(
title: context.l10n.youtubeOpusBitrateTitle, title: context.l10n.youtubeOpusBitrateTitle,
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)', subtitle:
'${settings.youtubeOpusBitrate}kbps (128/256/320)',
onTap: () => _showYoutubeBitratePicker( onTap: () => _showYoutubeBitratePicker(
context: context, context: context,
title: context.l10n.youtubeOpusBitrateTitle, title: context.l10n.youtubeOpusBitrateTitle,
@@ -515,8 +547,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
icon: Icons.translate_outlined, icon: Icons.translate_outlined,
title: context.l10n.downloadNeteaseIncludeTranslation, title: context.l10n.downloadNeteaseIncludeTranslation,
subtitle: settings.lyricsIncludeTranslationNetease subtitle: settings.lyricsIncludeTranslationNetease
? context.l10n.downloadNeteaseIncludeTranslationEnabled ? context
: context.l10n.downloadNeteaseIncludeTranslationDisabled, .l10n
.downloadNeteaseIncludeTranslationEnabled
: context
.l10n
.downloadNeteaseIncludeTranslationDisabled,
value: settings.lyricsIncludeTranslationNetease, value: settings.lyricsIncludeTranslationNetease,
onChanged: (value) => ref onChanged: (value) => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
@@ -526,8 +562,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
icon: Icons.text_fields_outlined, icon: Icons.text_fields_outlined,
title: context.l10n.downloadNeteaseIncludeRomanization, title: context.l10n.downloadNeteaseIncludeRomanization,
subtitle: settings.lyricsIncludeRomanizationNetease subtitle: settings.lyricsIncludeRomanizationNetease
? context.l10n.downloadNeteaseIncludeRomanizationEnabled ? context
: context.l10n.downloadNeteaseIncludeRomanizationDisabled, .l10n
.downloadNeteaseIncludeRomanizationEnabled
: context
.l10n
.downloadNeteaseIncludeRomanizationDisabled,
value: settings.lyricsIncludeRomanizationNetease, value: settings.lyricsIncludeRomanizationNetease,
onChanged: (value) => ref onChanged: (value) => ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
@@ -627,6 +667,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
settings.folderOrganization, settings.folderOrganization,
), ),
), ),
SettingsSwitchItem(
icon: Icons.playlist_play_outlined,
title: context.l10n.downloadCreatePlaylistSourceFolder,
subtitle: _getPlaylistFolderSubtitle(settings),
value: settings.createPlaylistFolder,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setCreatePlaylistFolder(value),
),
SettingsSwitchItem( SettingsSwitchItem(
icon: Icons.person_search_outlined, icon: Icons.person_search_outlined,
title: context.l10n.downloadUseAlbumArtistForFolders, title: context.l10n.downloadUseAlbumArtistForFolders,
@@ -642,7 +691,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value), .setUseAlbumArtistForFolders(value),
), ),
SettingsItem( SettingsItem(
icon: Icons.filter_alt_outlined, icon: Icons.filter_alt_outlined,
title: context.l10n.downloadArtistNameFilters, title: context.l10n.downloadArtistNameFilters,
subtitle: _getArtistFolderFilterSubtitle( subtitle: _getArtistFolderFilterSubtitle(
@@ -1142,7 +1191,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async { Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
if (Platform.isIOS) { if (Platform.isIOS) {
_showIOSDirectoryOptions(context, ref); _showIOSDirectoryOptions(context, ref);
} else { } else if (Platform.isAndroid) {
_showAndroidDirectoryOptions(context, ref); _showAndroidDirectoryOptions(context, ref);
} }
} }
@@ -1407,6 +1456,16 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
} }
} }
String _getPlaylistFolderSubtitle(AppSettings settings) {
if (settings.folderOrganization == 'playlist') {
return context.l10n.downloadCreatePlaylistSourceFolderRedundant;
}
if (settings.createPlaylistFolder) {
return context.l10n.downloadCreatePlaylistSourceFolderEnabled;
}
return context.l10n.downloadCreatePlaylistSourceFolderDisabled;
}
String _getArtistFolderFilterSubtitle( String _getArtistFolderFilterSubtitle(
BuildContext context, { BuildContext context, {
required bool usePrimaryArtistOnly, required bool usePrimaryArtistOnly,
@@ -1532,6 +1591,104 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), ''); return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
} }
String _getTidalHighFormatLabel(BuildContext context, String format) {
switch (format) {
case 'mp3_320':
return context.l10n.downloadLossyMp3;
case 'opus_256':
return context.l10n.downloadLossyOpus256;
case 'opus_128':
return context.l10n.downloadLossyOpus128;
default:
return context.l10n.downloadLossyMp3;
}
}
void _showTidalHighFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.downloadLossy320Format,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.downloadLossy320FormatDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.audiotrack),
title: Text(context.l10n.downloadLossyMp3),
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
trailing: current == 'mp3_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus256),
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
trailing: current == 'opus_256'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: Text(context.l10n.downloadLossyOpus128),
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
trailing: current == 'opus_128'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showYoutubeBitratePicker({ void _showYoutubeBitratePicker({
required BuildContext context, required BuildContext context,
required String title, required String title,
@@ -1776,17 +1933,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text( child: Text(
context.l10n.downloadSongLinkRegion, context.l10n.downloadSongLinkRegion,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
), ),
), Padding(
Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), child: Text(
child: Text( context.l10n.downloadSongLinkRegionDesc,
context.l10n.downloadSongLinkRegionDesc,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -1847,12 +2004,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text( child: Text(
context.l10n.downloadFolderOrganization, context.l10n.downloadFolderOrganization,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
+252 -57
View File
@@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/explore_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
@@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
_DownloadPriorityItem(), _DownloadPriorityItem(),
_MetadataPriorityItem(), _MetadataPriorityItem(),
_SearchProviderSelector(), _SearchProviderSelector(),
_HomeFeedProviderSelector(),
], ],
), ),
), ),
@@ -586,6 +588,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
class _SearchProviderSelector extends ConsumerWidget { class _SearchProviderSelector extends ConsumerWidget {
const _SearchProviderSelector(); const _SearchProviderSelector();
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
@@ -596,20 +600,29 @@ class _SearchProviderSelector extends ConsumerWidget {
.where((e) => e.enabled && e.hasCustomSearch) .where((e) => e.enabled && e.hasCustomSearch)
.toList(); .toList();
// Always allow tapping: built-in providers are always available
final hasAnyProvider =
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
String currentProviderName = context.l10n.extensionDefaultProvider; String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) { settings.searchProvider!.isNotEmpty) {
final ext = searchProviders // Check built-in first
.where((e) => e.id == settings.searchProvider) if (_builtInProviders.containsKey(settings.searchProvider)) {
.firstOrNull; currentProviderName = _builtInProviders[settings.searchProvider]!;
currentProviderName = ext?.displayName ?? settings.searchProvider!; } else {
final ext = searchProviders
.where((e) => e.id == settings.searchProvider)
.firstOrNull;
currentProviderName = ext?.displayName ?? settings.searchProvider!;
}
} }
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
InkWell( InkWell(
onTap: searchProviders.isEmpty onTap: !hasAnyProvider
? null ? null
: () => _showSearchProviderPicker( : () => _showSearchProviderPicker(
context, context,
@@ -623,7 +636,7 @@ class _SearchProviderSelector extends ConsumerWidget {
children: [ children: [
Icon( Icon(
Icons.manage_search, Icons.manage_search,
color: searchProviders.isEmpty color: !hasAnyProvider
? colorScheme.outline ? colorScheme.outline
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
@@ -635,14 +648,12 @@ class _SearchProviderSelector extends ConsumerWidget {
Text( Text(
context.l10n.extensionsSearchProvider, context.l10n.extensionsSearchProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: searchProviders.isEmpty color: !hasAnyProvider ? colorScheme.outline : null,
? colorScheme.outline
: null,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
searchProviders.isEmpty !hasAnyProvider
? context.l10n.extensionsNoCustomSearch ? context.l10n.extensionsNoCustomSearch
: currentProviderName, : currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -654,7 +665,7 @@ class _SearchProviderSelector extends ConsumerWidget {
), ),
Icon( Icon(
Icons.chevron_right, Icons.chevron_right,
color: searchProviders.isEmpty color: !hasAnyProvider
? colorScheme.outline ? colorScheme.outline
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
@@ -682,61 +693,245 @@ class _SearchProviderSelector extends ConsumerWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(28)), borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
), ),
builder: (ctx) => SafeArea( builder: (ctx) => SafeArea(
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Padding( children: [
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), Padding(
child: Text( padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
ctx.l10n.extensionsSearchProvider, child: Text(
style: Theme.of( ctx.l10n.extensionsSearchProvider,
context, style: Theme.of(
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), context,
), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
ctx.l10n.extensionsSearchProviderDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
), ),
), ),
), Padding(
ListTile( padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
leading: Icon(Icons.music_note, color: colorScheme.primary), child: Text(
title: Text(ctx.l10n.extensionDefaultProvider), ctx.l10n.extensionsSearchProviderDescription,
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
trailing: color: colorScheme.onSurfaceVariant,
(settings.searchProvider == null || ),
settings.searchProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref.read(settingsProvider.notifier).setSearchProvider(null);
Navigator.pop(ctx);
},
),
...searchProviders.map(
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(
ext.searchBehavior?.placeholder ??
ctx.l10n.extensionsCustomSearch,
), ),
trailing: settings.searchProvider == ext.id ),
ListTile(
leading: Icon(Icons.music_note, color: colorScheme.primary),
title: Text(ctx.l10n.extensionDefaultProvider),
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
trailing:
(settings.searchProvider == null ||
settings.searchProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary) ? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline), : Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () { onTap: () {
ref.read(settingsProvider.notifier).setSearchProvider(ext.id); ref.read(settingsProvider.notifier).setSearchProvider(null);
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
), ),
), ..._builtInProviders.entries.map(
const SizedBox(height: 16), (entry) => ListTile(
], leading: Icon(Icons.search, color: colorScheme.tertiary),
title: Text(entry.value),
subtitle: Text('Search with ${entry.value}'),
trailing: settings.searchProvider == entry.key
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider(entry.key);
Navigator.pop(ctx);
},
),
),
if (searchProviders.isNotEmpty) const Divider(height: 1),
...searchProviders.map(
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text(
ext.searchBehavior?.placeholder ??
ctx.l10n.extensionsCustomSearch,
),
trailing: settings.searchProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider(ext.id);
Navigator.pop(ctx);
},
),
),
const SizedBox(height: 16),
],
),
),
),
);
}
}
class _HomeFeedProviderSelector extends ConsumerWidget {
const _HomeFeedProviderSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final homeFeedProviders = extState.extensions
.where((e) => e.enabled && e.hasHomeFeed)
.toList();
final hasAnyProvider = homeFeedProviders.isNotEmpty;
String currentProviderName = 'Auto';
if (settings.homeFeedProvider != null &&
settings.homeFeedProvider!.isNotEmpty) {
final ext = homeFeedProviders
.where((e) => e.id == settings.homeFeedProvider)
.firstOrNull;
currentProviderName = ext?.displayName ?? settings.homeFeedProvider!;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: !hasAnyProvider
? null
: () => _showHomeFeedProviderPicker(
context,
ref,
settings,
homeFeedProviders,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
Icons.explore_outlined,
color: !hasAnyProvider
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Home Feed Provider',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: !hasAnyProvider ? colorScheme.outline : null,
),
),
const SizedBox(height: 2),
Text(
!hasAnyProvider
? 'No extensions with home feed'
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: !hasAnyProvider
? colorScheme.outline
: colorScheme.onSurfaceVariant,
),
],
),
),
),
],
);
}
void _showHomeFeedProviderPicker(
BuildContext context,
WidgetRef ref,
dynamic settings,
List<Extension> homeFeedProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Home Feed Provider',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose which extension provides the home feed on the main screen',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
title: const Text('Auto'),
subtitle: const Text('Automatically select the best available'),
trailing:
(settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty)
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref.read(settingsProvider.notifier).setHomeFeedProvider(null);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
),
...homeFeedProviders.map(
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text('Use ${ext.displayName} home feed'),
trailing: settings.homeFeedProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setHomeFeedProvider(ext.id);
ref.read(exploreProvider.notifier).refresh();
Navigator.pop(ctx);
},
),
),
const SizedBox(height: 16),
],
),
), ),
), ),
); );
+37 -21
View File
@@ -73,11 +73,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents // iOS doesn't need explicit storage permission for app documents
setState(() => _hasStoragePermission = true); setState(() => _hasStoragePermission = true);
} else {
setState(() => _hasStoragePermission = true);
} }
} }
Future<bool> _requestStoragePermission() async { Future<bool> _requestStoragePermission() async {
if (Platform.isIOS) return true; if (!Platform.isAndroid) return true;
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE // SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
if (_androidSdkVersion >= 29) return true; if (_androidSdkVersion >= 29) return true;
@@ -135,8 +137,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
if (Platform.isIOS) { if (Platform.isIOS) {
// On iOS, create a security-scoped bookmark so we can access // On iOS, create a security-scoped bookmark so we can access
// this folder across app restarts and from the Go backend. // this folder across app restarts and from the Go backend.
final bookmark = final bookmark = await PlatformBridge.createIosBookmarkFromPath(
await PlatformBridge.createIosBookmarkFromPath(result); result,
);
if (bookmark != null && bookmark.isNotEmpty) { if (bookmark != null && bookmark.isNotEmpty) {
ref ref
.read(settingsProvider.notifier) .read(settingsProvider.notifier)
@@ -182,11 +185,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return; return;
} }
await ref.read(localLibraryProvider.notifier).startScan( await ref
libraryPath, .read(localLibraryProvider.notifier)
forceFullScan: forceFullScan, .startScan(
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, libraryPath,
); forceFullScan: forceFullScan,
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
} }
Future<void> _cancelScan() async { Future<void> _cancelScan() async {
@@ -272,10 +277,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text( child: Text(
context.l10n.libraryAutoScan, context.l10n.libraryAutoScan,
style: Theme.of(context) style: Theme.of(
.textTheme context,
.titleLarge ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
Padding( Padding(
@@ -293,7 +297,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
selected: current == 'off', selected: current == 'off',
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () { onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off'); ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('off');
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@@ -303,7 +309,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
selected: current == 'on_open', selected: current == 'on_open',
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () { onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open'); ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('on_open');
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@@ -313,7 +321,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
selected: current == 'daily', selected: current == 'daily',
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () { onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily'); ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('daily');
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@@ -323,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
selected: current == 'weekly', selected: current == 'weekly',
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () { onTap: () {
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly'); ref
.read(settingsProvider.notifier)
.setLocalLibraryAutoScan('weekly');
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@@ -443,9 +455,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
child: SettingsItem( child: SettingsItem(
icon: Icons.autorenew_rounded, icon: Icons.autorenew_rounded,
title: context.l10n.libraryAutoScan, title: context.l10n.libraryAutoScan,
subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan), subtitle: _getAutoScanLabel(
context,
settings.localLibraryAutoScan,
),
onTap: settings.localLibraryEnabled onTap: settings.localLibraryEnabled
? () => _showAutoScanPicker(context, settings.localLibraryAutoScan) ? () => _showAutoScanPicker(
context,
settings.localLibraryAutoScan,
)
: null, : null,
showDivider: false, showDivider: false,
), ),
@@ -950,9 +968,7 @@ class _AutoScanOption extends StatelessWidget {
return ListTile( return ListTile(
leading: Icon(icon), leading: Icon(icon),
title: Text(title), title: Text(title),
trailing: selected trailing: selected ? Icon(Icons.check, color: colorScheme.primary) : null,
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: onTap, onTap: onTap,
); );
} }
+46 -14
View File
@@ -611,24 +611,34 @@ class _MetadataSourceSelector extends ConsumerWidget {
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _MetadataSourceSelector({required this.onChanged}); const _MetadataSourceSelector({required this.onChanged});
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final searchProvider = settings.searchProvider ?? '';
final isBuiltIn = _builtInProviders.containsKey(searchProvider);
Extension? activeExtension; Extension? activeExtension;
if (settings.searchProvider != null && if (searchProvider.isNotEmpty && !isBuiltIn) {
settings.searchProvider!.isNotEmpty) {
activeExtension = extState.extensions activeExtension = extState.extensions
.where((e) => e.id == settings.searchProvider && e.enabled) .where((e) => e.id == searchProvider && e.enabled)
.firstOrNull; .firstOrNull;
} }
final hasExtensionSearch = activeExtension != null; final hasNonDefaultProvider = isBuiltIn || activeExtension != null;
String? extensionName; String subtitle;
if (hasExtensionSearch) { if (isBuiltIn) {
extensionName = activeExtension.displayName; subtitle = 'Using ${_builtInProviders[searchProvider]}';
} else if (activeExtension != null) {
subtitle = context.l10n.optionsUsingExtension(
activeExtension.displayName,
);
} else {
subtitle = context.l10n.optionsPrimaryProviderSubtitle;
} }
return Padding( return Padding(
@@ -644,11 +654,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
hasExtensionSearch subtitle,
? context.l10n.optionsUsingExtension(extensionName!)
: context.l10n.optionsPrimaryProviderSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasExtensionSearch color: hasNonDefaultProvider
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
@@ -659,17 +667,41 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip( _SourceChip(
icon: Icons.graphic_eq, icon: Icons.graphic_eq,
label: 'Deezer', label: 'Deezer',
isSelected: !hasExtensionSearch, isSelected: searchProvider.isEmpty,
onTap: () { onTap: () {
if (hasExtensionSearch) { if (hasNonDefaultProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null); ref.read(settingsProvider.notifier).setSearchProvider(null);
} }
onChanged('deezer'); onChanged('deezer');
}, },
), ),
const SizedBox(width: 8),
_SourceChip(
icon: Icons.waves,
label: 'Tidal',
isSelected: searchProvider == 'tidal',
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('tidal');
onChanged('tidal');
},
),
const SizedBox(width: 8),
_SourceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: searchProvider == 'qobuz',
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('qobuz');
onChanged('qobuz');
},
),
], ],
), ),
if (hasExtensionSearch) ...[ if (activeExtension != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
+8 -1
View File
@@ -91,6 +91,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_notificationPermissionGranted = notificationStatus.isGranted; _notificationPermissionGranted = notificationStatus.isGranted;
}); });
} }
} else {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
});
} }
} }
@@ -139,6 +144,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)), SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
); );
} }
} else {
setState(() => _storagePermissionGranted = true);
} }
} catch (e) { } catch (e) {
debugPrint('Permission error: $e'); debugPrint('Permission error: $e');
@@ -225,7 +232,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
try { try {
if (Platform.isIOS) { if (Platform.isIOS) {
await _showIOSDirectoryOptions(); await _showIOSDirectoryOptions();
} else { } else if (Platform.isAndroid) {
final result = await PlatformBridge.pickSafTree(); final result = await PlatformBridge.pickSafTree();
if (result != null) { if (result != null) {
final treeUri = result['tree_uri'] as String? ?? ''; final treeUri = result['tree_uri'] as String? ?? '';
+57 -17
View File
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart';
@@ -518,7 +519,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _filePath => String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl; String? get _coverUrl =>
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
String? get _localCoverPath => String? get _localCoverPath =>
_isLocalItem ? _localLibraryItem!.coverPath : null; _isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
@@ -1778,6 +1780,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final isFlac = lower.endsWith('.flac'); final isFlac = lower.endsWith('.flac');
final isMp3 = lower.endsWith('.mp3'); final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
bool success = false; bool success = false;
String? error; String? error;
@@ -1803,7 +1806,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} else { } else {
error = result['error']?.toString() ?? l10nFailedToEmbedLyrics; error = result['error']?.toString() ?? l10nFailedToEmbedLyrics;
} }
} else if (isMp3 || isOpus) { } else if (isMp3 || isOpus || isM4A) {
final metadata = _buildFallbackMetadata(); final metadata = _buildFallbackMetadata();
try { try {
final result = await PlatformBridge.readFileMetadata(workingPath); final result = await PlatformBridge.readFileMetadata(workingPath);
@@ -1838,6 +1841,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
coverPath: coverPath, coverPath: coverPath,
metadata: metadata, metadata: metadata,
); );
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: workingPath,
coverPath: coverPath,
metadata: metadata,
);
} else { } else {
ffmpegResult = await FFmpegService.embedMetadataToOpus( ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: workingPath, opusPath: workingPath,
@@ -2321,6 +2330,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
coverPath: effectiveCoverPath, coverPath: effectiveCoverPath,
metadata: metadata, metadata: metadata,
); );
} else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
);
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ffmpegResult = await FFmpegService.embedMetadataToOpus( ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget, opusPath: ffmpegTarget,
@@ -2737,6 +2752,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
put('COPYRIGHT', source['copyright']); put('COPYRIGHT', source['copyright']);
put('COMPOSER', source['composer']); put('COMPOSER', source['composer']);
put('COMMENT', source['comment']); put('COMMENT', source['comment']);
put('LYRICS', source['lyrics']);
put('UNSYNCEDLYRICS', source['lyrics']);
final trackNumber = source['track_number']; final trackNumber = source['track_number'];
if (trackNumber != null && trackNumber.toString() != '0') { if (trackNumber != null && trackNumber.toString() != '0') {
@@ -2796,8 +2813,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
void _showConvertSheet(BuildContext context) { void _showConvertSheet(BuildContext context) {
final currentFormat = _currentFileFormat; final currentFormat = _currentFileFormat;
final isLosslessSource = final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
currentFormat == 'FLAC' || currentFormat == 'M4A';
// Build available target formats based on source // Build available target formats based on source
final formats = <String>[]; final formats = <String>[];
@@ -2879,8 +2895,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
isLosslessTarget = isLosslessTarget =
format == 'ALAC' || format == 'FLAC'; format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) { if (!isLosslessTarget) {
selectedBitrate = selectedBitrate = format == 'Opus'
format == 'Opus' ? '128k' : '320k'; ? '128k'
: '320k';
} }
}); });
} }
@@ -2929,11 +2946,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.trackConvertLosslessHint, context.l10n.trackConvertLosslessHint,
style: Theme.of( style: Theme.of(context).textTheme.bodySmall
context, ?.copyWith(color: colorScheme.primary),
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
), ),
], ],
), ),
@@ -3499,22 +3513,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
SnackBar(content: Text(context.l10n.trackConvertConverting)), SnackBar(content: Text(context.l10n.trackConvertConverting)),
); );
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
final metadata = _buildFallbackMetadata(); final metadata = _buildFallbackMetadata();
try { try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath); final result = await PlatformBridge.readFileMetadata(cleanFilePath);
if (result['error'] == null) { if (result['error'] == null) {
result.forEach((key, value) { mergePlatformMetadataForTagEmbed(target: metadata, source: result);
if (key == 'error' || value == null) return;
final normalizedValue = value.toString().trim();
if (normalizedValue.isEmpty) return;
metadata[key.toUpperCase()] = normalizedValue;
});
} else { } else {
_log.w('readFileMetadata returned error, using fallback metadata'); _log.w('readFileMetadata returned error, using fallback metadata');
} }
} catch (e) { } catch (e) {
_log.w('readFileMetadata threw, using fallback metadata: $e'); _log.w('readFileMetadata threw, using fallback metadata: $e');
} }
await ensureLyricsMetadataForConversion(
metadata: metadata,
sourcePath: cleanFilePath,
shouldEmbedLyrics: shouldEmbedLyrics,
trackName: trackName,
artistName: artistName,
spotifyId: _spotifyId ?? '',
durationMs: (duration ?? 0) * 1000,
);
String? coverPath; String? coverPath;
try { try {
@@ -4921,6 +4942,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final lower = widget.filePath.toLowerCase(); final lower = widget.filePath.toLowerCase();
final isMp3 = lower.endsWith('.mp3'); final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
final vorbisMap = <String, String>{}; final vorbisMap = <String, String>{};
if (metadata['title']?.isNotEmpty == true) { if (metadata['title']?.isNotEmpty == true) {
@@ -4964,6 +4986,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (metadata['comment']?.isNotEmpty == true) { if (metadata['comment']?.isNotEmpty == true) {
vorbisMap['COMMENT'] = metadata['comment']!; vorbisMap['COMMENT'] = metadata['comment']!;
} }
try {
final existingMetadata = await PlatformBridge.readFileMetadata(
ffmpegTarget,
);
final existingLyrics = existingMetadata['lyrics']?.toString().trim();
if (existingLyrics != null && existingLyrics.isNotEmpty) {
vorbisMap['LYRICS'] = existingLyrics;
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
}
} catch (_) {
// Lyrics preservation is best-effort.
}
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath; String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
String? extractedCoverPath; String? extractedCoverPath;
@@ -4997,6 +5031,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
coverPath: existingCoverPath, coverPath: existingCoverPath,
metadata: vorbisMap, metadata: vorbisMap,
); );
} else if (isM4A) {
ffmpegResult = await FFmpegService.embedMetadataToM4a(
m4aPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
} else if (isOpus) { } else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus( ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget, opusPath: ffmpegTarget,
+149 -24
View File
@@ -130,6 +130,25 @@ class FFmpegService {
} }
} }
static Future<FFmpegResult> _executeWithArguments(
List<String> arguments,
) async {
try {
final session = await FFmpegKit.executeWithArguments(arguments);
final returnCode = await session.getReturnCode();
final output = await session.getOutput() ?? '';
return FFmpegResult(
success: ReturnCode.isSuccess(returnCode),
returnCode: returnCode?.getValue() ?? -1,
output: output,
);
} catch (e) {
_log.e('FFmpeg executeWithArguments error: $e');
return FFmpegResult(success: false, returnCode: -1, output: e.toString());
}
}
static Future<String?> convertM4aToFlac(String inputPath) async { static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = _buildOutputPath(inputPath, '.flac'); final outputPath = _buildOutputPath(inputPath, '.flac');
@@ -1030,18 +1049,24 @@ class FFmpegService {
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final arguments = <String>[
final StringBuffer cmdBuffer = StringBuffer(); '-i',
cmdBuffer.write('-i "$opusPath" '); opusPath,
cmdBuffer.write('-map 0:a '); '-map',
cmdBuffer.write('-map_metadata -1 '); '0:a',
cmdBuffer.write('-map_metadata:s:a -1 '); '-map_metadata',
cmdBuffer.write('-c:a copy '); '-1',
'-map_metadata:s:a',
'-1',
'-c:a',
'copy',
];
if (metadata != null) { if (metadata != null) {
metadata.forEach((key, value) { metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"'); arguments
cmdBuffer.write('-metadata $key="$sanitizedValue" '); ..add('-metadata')
..add('$key=$value');
}); });
} }
@@ -1049,8 +1074,9 @@ class FFmpegService {
try { try {
final pictureBlock = await _createMetadataBlockPicture(coverPath); final pictureBlock = await _createMetadataBlockPicture(coverPath);
if (pictureBlock != null) { if (pictureBlock != null) {
final escapedBlock = pictureBlock.replaceAll('"', '\\"'); arguments
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" '); ..add('-metadata')
..add('METADATA_BLOCK_PICTURE=$pictureBlock');
_log.d( _log.d(
'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)', 'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)',
); );
@@ -1062,12 +1088,12 @@ class FFmpegService {
} }
} }
cmdBuffer.write('"$tempOutput" -y'); arguments
..add(tempOutput)
final command = cmdBuffer.toString(); ..add('-y');
_log.d('Executing FFmpeg Opus embed command'); _log.d('Executing FFmpeg Opus embed command');
final result = await _execute(command); final result = await _executeWithArguments(arguments);
if (result.success) { if (result.success) {
try { try {
@@ -1106,6 +1132,88 @@ class FFmpegService {
return null; return null;
} }
static Future<String?> embedMetadataToM4a({
required String m4aPath,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$m4aPath" ');
final hasCover = coverPath != null && await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
// For M4A/MP4, cover art is mapped as a video stream and stored in the
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
// flag is only valid for Matroska/WebM containers and must NOT be used here.
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
final m4aMetadata = _convertToM4aTags(metadata);
for (final entry in m4aMetadata.entries) {
final sanitizedValue = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitizedValue" ');
}
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d(
'Executing FFmpeg M4A embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command);
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(m4aPath);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(m4aPath);
await tempFile.delete();
_log.d('M4A metadata embedded successfully');
return m4aPath;
} else {
_log.e('Temp M4A output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace M4A file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup temp M4A file: $e');
}
_log.e('M4A Metadata embed failed: ${result.output}');
return null;
}
static Future<String?> _createMetadataBlockPicture(String imagePath) async { static Future<String?> _createMetadataBlockPicture(String imagePath) async {
try { try {
final file = File(imagePath); final file = File(imagePath);
@@ -1330,7 +1438,8 @@ class FFmpegService {
cmdBuffer.write('-i "$inputPath" '); cmdBuffer.write('-i "$inputPath" ');
// Cover art as second input for M4A attached picture // Cover art as second input for M4A attached picture
final hasCover = coverPath != null && final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty && coverPath.trim().isNotEmpty &&
await File(coverPath).exists(); await File(coverPath).exists();
if (hasCover) { if (hasCover) {
@@ -1338,8 +1447,10 @@ class FFmpegService {
} }
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
// M4A/MP4 containers store cover art in the 'covr' atom automatically.
// '-disposition attached_pic' is only for Matroska/WebM and must NOT be used here.
if (hasCover) { if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic '); cmdBuffer.write('-map 1:v -c:v copy ');
} }
cmdBuffer.write('-c:a alac '); cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 '); cmdBuffer.write('-map_metadata -1 ');
@@ -1389,7 +1500,8 @@ class FFmpegService {
final cmdBuffer = StringBuffer(); final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" '); cmdBuffer.write('-i "$inputPath" ');
final hasCover = coverPath != null && final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty && coverPath.trim().isNotEmpty &&
await File(coverPath).exists(); await File(coverPath).exists();
if (hasCover) { if (hasCover) {
@@ -1508,9 +1620,7 @@ class FFmpegService {
} }
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
static Map<String, String> _convertToM4aTags( static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
Map<String, String> metadata,
) {
final m4aMap = <String, String>{}; final m4aMap = <String, String>{};
for (final entry in metadata.entries) { for (final entry in metadata.entries) {
@@ -1548,6 +1658,9 @@ class FFmpegService {
case 'GENRE': case 'GENRE':
m4aMap['genre'] = value; m4aMap['genre'] = value;
break; break;
case 'ISRC':
m4aMap['isrc'] = value;
break;
case 'COMPOSER': case 'COMPOSER':
m4aMap['composer'] = value; m4aMap['composer'] = value;
break; break;
@@ -1557,6 +1670,10 @@ class FFmpegService {
case 'COPYRIGHT': case 'COPYRIGHT':
m4aMap['copyright'] = value; m4aMap['copyright'] = value;
break; break;
case 'LABEL':
case 'ORGANIZATION':
m4aMap['organization'] = value;
break;
case 'LYRICS': case 'LYRICS':
case 'UNSYNCEDLYRICS': case 'UNSYNCEDLYRICS':
m4aMap['lyrics'] = value; m4aMap['lyrics'] = value;
@@ -1648,7 +1765,11 @@ class FFmpegService {
final outputPaths = <String>[]; final outputPaths = <String>[];
final inputExt = audioPath.toLowerCase().split('.').last; final inputExt = audioPath.toLowerCase().split('.').last;
// For lossless formats, keep as FLAC; for others, keep original format // For lossless formats, keep as FLAC; for others, keep original format
final outputExt = (inputExt == 'flac' || inputExt == 'wav' || inputExt == 'ape' || inputExt == 'wv') final outputExt =
(inputExt == 'flac' ||
inputExt == 'wav' ||
inputExt == 'ape' ||
inputExt == 'wv')
? 'flac' ? 'flac'
: inputExt; : inputExt;
@@ -1681,7 +1802,9 @@ class FFmpegService {
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
} }
final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? ''); final artist = track.artist.isNotEmpty
? track.artist
: (albumMetadata['artist'] ?? '');
final album = albumMetadata['album'] ?? ''; final album = albumMetadata['album'] ?? '';
final genre = albumMetadata['genre'] ?? ''; final genre = albumMetadata['genre'] ?? '';
final date = albumMetadata['date'] ?? ''; final date = albumMetadata['date'] ?? '';
@@ -1706,7 +1829,9 @@ class FFmpegService {
cmdBuffer.write('"$outputPath" -y'); cmdBuffer.write('"$outputPath" -y');
final command = cmdBuffer.toString(); final command = cmdBuffer.toString();
_log.d('CUE split track ${track.number}: ${_previewCommandForLog(command)}'); _log.d(
'CUE split track ${track.number}: ${_previewCommandForLog(command)}',
);
final result = await _execute(command); final result = await _execute(command);
if (!result.success) { if (!result.success) {
+36
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
@@ -14,6 +15,11 @@ class PlatformBridge {
'com.zarz.spotiflac/library_scan_progress_stream', 'com.zarz.spotiflac/library_scan_progress_stream',
); );
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
static bool get supportsExtensionSystem =>
Platform.isAndroid || Platform.isIOS;
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async { static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
_log.d('parseSpotifyUrl: $url'); _log.d('parseSpotifyUrl: $url');
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
@@ -503,6 +509,36 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
static Future<Map<String, dynamic>> searchTidalAll(
String query, {
int trackLimit = 15,
int artistLimit = 2,
String? filter,
}) async {
final result = await _channel.invokeMethod('searchTidalAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
'filter': filter ?? '',
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchQobuzAll(
String query, {
int trackLimit = 15,
int artistLimit = 2,
String? filter,
}) async {
final result = await _channel.invokeMethod('searchQobuzAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
'filter': filter ?? '',
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getDeezerRelatedArtists( static Future<Map<String, dynamic>> getDeezerRelatedArtists(
String artistId, { String artistId, {
int limit = 12, int limit = 12,
+15 -8
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
@@ -10,8 +11,9 @@ class ShareIntentService {
ShareIntentService._internal(); ShareIntentService._internal();
// Spotify patterns // Spotify patterns
static final RegExp _spotifyUriPattern = static final RegExp _spotifyUriPattern = RegExp(
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+'); r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+',
);
static final RegExp _spotifyUrlPattern = RegExp( static final RegExp _spotifyUrlPattern = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
); );
@@ -56,6 +58,11 @@ class ShareIntentService {
if (_initialized) return; if (_initialized) return;
_initialized = true; _initialized = true;
if (!Platform.isAndroid && !Platform.isIOS) {
_log.i('Share intent is not supported on this platform');
return;
}
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
_handleSharedMedia, _handleSharedMedia,
onError: (err) => _log.e('Error: $err'), onError: (err) => _log.e('Error: $err'),
@@ -68,14 +75,14 @@ class ShareIntentService {
} }
} }
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) { void _handleSharedMedia(
List<SharedMediaFile> files, {
bool isInitial = false,
}) {
for (final file in files) { for (final file in files) {
// Check both path and message - apps may share URL in either field // Check both path and message - apps may share URL in either field
final textsToCheck = [ final textsToCheck = [file.path, if (file.message != null) file.message!];
file.path,
if (file.message != null) file.message!,
];
for (final textToCheck in textsToCheck) { for (final textToCheck in textsToCheck) {
final url = _extractMusicUrl(textToCheck); final url = _extractMusicUrl(textToCheck);
if (url != null) { if (url != null) {
+42 -24
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
@@ -24,20 +25,28 @@ class UpdateInfo {
} }
class UpdateChecker { class UpdateChecker {
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; static const String _latestApiUrl =
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
static const String _allReleasesApiUrl =
'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
/// Check for updates based on channel preference /// Check for updates based on channel preference
/// [channel] can be 'stable' or 'preview' /// [channel] can be 'stable' or 'preview'
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async { static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
if (!Platform.isAndroid) {
return null;
}
try { try {
Map<String, dynamic>? releaseData; Map<String, dynamic>? releaseData;
if (channel == 'preview') { if (channel == 'preview') {
final response = await http.get( final response = await http
Uri.parse('$_allReleasesApiUrl?per_page=10'), .get(
headers: {'Accept': 'application/vnd.github.v3+json'}, Uri.parse('$_allReleasesApiUrl?per_page=10'),
).timeout(const Duration(seconds: 10)); headers: {'Accept': 'application/vnd.github.v3+json'},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) { if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}'); _log.w('GitHub API returned ${response.statusCode}');
@@ -49,13 +58,15 @@ class UpdateChecker {
_log.i('No releases found'); _log.i('No releases found');
return null; return null;
} }
releaseData = releases.first as Map<String, dynamic>; releaseData = releases.first as Map<String, dynamic>;
} else { } else {
final response = await http.get( final response = await http
Uri.parse(_latestApiUrl), .get(
headers: {'Accept': 'application/vnd.github.v3+json'}, Uri.parse(_latestApiUrl),
).timeout(const Duration(seconds: 10)); headers: {'Accept': 'application/vnd.github.v3+json'},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) { if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}'); _log.w('GitHub API returned ${response.statusCode}');
@@ -68,19 +79,24 @@ class UpdateChecker {
final tagName = releaseData['tag_name'] as String? ?? ''; final tagName = releaseData['tag_name'] as String? ?? '';
final latestVersion = tagName.replaceFirst('v', ''); final latestVersion = tagName.replaceFirst('v', '');
final isPrerelease = releaseData['prerelease'] as bool? ?? false; final isPrerelease = releaseData['prerelease'] as bool? ?? false;
if (!_isNewerVersion(latestVersion, AppInfo.version)) { if (!_isNewerVersion(latestVersion, AppInfo.version)) {
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)'); _log.i(
'No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)',
);
return null; return null;
} }
final body = releaseData['body'] as String? ?? 'No changelog available'; final body = releaseData['body'] as String? ?? 'No changelog available';
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final htmlUrl =
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt =
DateTime.tryParse(releaseData['published_at'] as String? ?? '') ??
DateTime.now();
String? arm64Url; String? arm64Url;
String? universalUrl; String? universalUrl;
final assets = releaseData['assets'] as List<dynamic>? ?? []; final assets = releaseData['assets'] as List<dynamic>? ?? [];
for (final asset in assets) { for (final asset in assets) {
final name = (asset['name'] as String? ?? '').toLowerCase(); final name = (asset['name'] as String? ?? '').toLowerCase();
@@ -98,12 +114,14 @@ class UpdateChecker {
} }
} }
} }
// Only arm64 is supported; fall back to universal if available // Only arm64 is supported; fall back to universal if available
final apkUrl = arm64Url ?? universalUrl; final apkUrl = arm64Url ?? universalUrl;
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl'); _log.i(
'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl',
);
return UpdateInfo( return UpdateInfo(
version: latestVersion, version: latestVersion,
changelog: body, changelog: body,
@@ -122,7 +140,7 @@ class UpdateChecker {
try { try {
final latestBase = latest.split('-').first; final latestBase = latest.split('-').first;
final currentBase = current.split('-').first; final currentBase = current.split('-').first;
final latestParts = latestBase.split('.').map(int.parse).toList(); final latestParts = latestBase.split('.').map(int.parse).toList();
final currentParts = currentBase.split('.').map(int.parse).toList(); final currentParts = currentBase.split('.').map(int.parse).toList();
@@ -137,12 +155,12 @@ class UpdateChecker {
if (latestParts[i] > currentParts[i]) return true; if (latestParts[i] > currentParts[i]) return true;
if (latestParts[i] < currentParts[i]) return false; if (latestParts[i] < currentParts[i]) return false;
} }
final latestHasSuffix = latest.contains('-'); final latestHasSuffix = latest.contains('-');
final currentHasSuffix = current.contains('-'); final currentHasSuffix = current.contains('-');
if (!latestHasSuffix && currentHasSuffix) return true; if (!latestHasSuffix && currentHasSuffix) return true;
return false; return false;
} catch (e) { } catch (e) {
_log.e('Error comparing versions: $e'); _log.e('Error comparing versions: $e');
+35
View File
@@ -74,3 +74,38 @@ Future<void> ensureLyricsMetadataForConversion({
metadata['LYRICS'] = lyrics; metadata['LYRICS'] = lyrics;
metadata['UNSYNCEDLYRICS'] = lyrics; metadata['UNSYNCEDLYRICS'] = lyrics;
} }
void mergePlatformMetadataForTagEmbed({
required Map<String, String> target,
required Map<String, dynamic> source,
}) {
void put(String key, dynamic value) {
final normalized = value?.toString().trim();
if (normalized == null || normalized.isEmpty) return;
target[key] = normalized;
}
put('TITLE', source['title']);
put('ARTIST', source['artist']);
put('ALBUM', source['album']);
put('ALBUMARTIST', source['album_artist']);
put('DATE', source['date']);
put('ISRC', source['isrc']);
put('GENRE', source['genre']);
put('ORGANIZATION', source['label']);
put('COPYRIGHT', source['copyright']);
put('COMPOSER', source['composer']);
put('COMMENT', source['comment']);
put('LYRICS', source['lyrics']);
put('UNSYNCEDLYRICS', source['lyrics']);
final trackNumber = source['track_number'];
if (trackNumber != null && trackNumber.toString() != '0') {
put('TRACKNUMBER', trackNumber);
}
final discNumber = source['disc_number'];
if (discNumber != null && discNumber.toString() != '0') {
put('DISCNUMBER', discNumber);
}
}
+35
View File
@@ -6,6 +6,41 @@ String? normalizeOptionalString(String? value) {
return trimmed; return trimmed;
} }
final RegExp _windowsAbsolutePathPattern = RegExp(r'^[A-Za-z]:[\\/]');
bool _looksLikeLocalReference(String value) {
return value.startsWith('/') ||
value.startsWith('content://') ||
value.startsWith('file://') ||
_windowsAbsolutePathPattern.hasMatch(value);
}
String? normalizeCoverReference(String? value) {
final normalized = normalizeOptionalString(value);
if (normalized == null) return null;
if (normalized.startsWith('//')) {
return 'https:$normalized';
}
if (normalized.startsWith('http://') ||
normalized.startsWith('https://') ||
_looksLikeLocalReference(normalized)) {
return normalized;
}
return null;
}
String? normalizeRemoteHttpUrl(String? value) {
final normalized = normalizeCoverReference(value);
if (normalized == null) return null;
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
return normalized;
}
return null;
}
String formatSampleRateKHz(int sampleRate) { String formatSampleRateKHz(int sampleRate) {
final khz = sampleRate / 1000; final khz = sampleRate / 1000;
final precision = sampleRate % 1000 == 0 ? 0 : 1; final precision = sampleRate % 1000 == 0 ? 0 : 1;
+13 -1
View File
@@ -102,6 +102,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
final String? artistName; final String? artistName;
final String? coverUrl; final String? coverUrl;
final void Function(String quality, String service) onSelect; final void Function(String quality, String service) onSelect;
final String? recommendedService; // Service to show as "(Recommended)"
const DownloadServicePicker({ const DownloadServicePicker({
super.key, super.key,
@@ -109,6 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
this.artistName, this.artistName,
this.coverUrl, this.coverUrl,
required this.onSelect, required this.onSelect,
this.recommendedService,
}); });
@override @override
@@ -121,6 +123,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
String? trackName, String? trackName,
String? artistName, String? artistName,
String? coverUrl, String? coverUrl,
String? recommendedService,
required void Function(String quality, String service) onSelect, required void Function(String quality, String service) onSelect,
}) { }) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -138,6 +141,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
artistName: artistName, artistName: artistName,
coverUrl: coverUrl, coverUrl: coverUrl,
onSelect: onSelect, onSelect: onSelect,
recommendedService: recommendedService,
), ),
); );
} }
@@ -152,7 +156,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedService = ref.read(settingsProvider).defaultService; // Default to recommended service if available, otherwise use default
final recommended = widget.recommendedService;
if (recommended != null && recommended.isNotEmpty) {
_selectedService = recommended;
} else {
_selectedService = ref.read(settingsProvider).defaultService;
}
} }
/// Get quality options for the selected service /// Get quality options for the selected service
@@ -282,6 +292,8 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
_ServiceChip( _ServiceChip(
label: service.isDisabled label: service.isDisabled
? '${service.label} (${service.disabledReason})' ? '${service.label} (${service.disabledReason})'
: widget.recommendedService == service.id
? '${service.label} (Recommended)'
: service.label, : service.label,
isSelected: _selectedService == service.id, isSelected: _selectedService == service.id,
isDisabled: service.isDisabled, isDisabled: service.isDisabled,
+40
View File
@@ -169,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
code_builder: code_builder:
dependency: transitive dependency: transitive
description: description:
@@ -509,6 +517,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -661,6 +677,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.6.1" version: "5.6.1"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
nm: nm:
dependency: transitive dependency: transitive
description: description:
@@ -1082,6 +1106,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.6" version: "2.5.6"
sqflite_common_ffi:
dependency: "direct main"
description:
name: sqflite_common_ffi
sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff
url: "https://pub.dev"
source: hosted
version: "2.4.0+2"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -1098,6 +1130,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91
url: "https://pub.dev"
source: hosted
version: "3.2.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
+2 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none" publish_to: "none"
version: 3.8.8+114 version: 3.9.0+115
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -27,6 +27,7 @@ dependencies:
path_provider: ^2.1.5 path_provider: ^2.1.5
path: ^1.9.0 path: ^1.9.0
sqflite: ^2.4.1 sqflite: ^2.4.1
sqflite_common_ffi: ^2.3.6
# HTTP & Network # HTTP & Network
http: ^1.6.0 http: ^1.6.0