Compare commits

...

29 Commits

Author SHA1 Message Date
zarzet 529a920b24 bump version to 3.8.5+111 2026-03-16 20:35:59 +07:00
zarzet 09eb6cf206 fix: use album-level artist for Various Artists albums instead of first track's artist
- Extension: fix extractSchemaOrg to find album-level schema (with numTracks) instead of per-track schema
- Extension: add secondaryText2 fallback in parseDescriptiveRows for VA album track artists
- Extension: use headerPrimaryText as primary album artist source, overriding schema.org
- App: album_screen now uses widget.artistName (album-level) instead of tracks.first.artistName
- App: home_tab _parseTrack now populates albumArtist from track data or album-level artist
- Bump Amazon extension to v2.0.1
2026-03-16 20:35:58 +07:00
zarzet af6fa6ea53 fix: extract cover art from M4A/ALAC files for conversion
Add extractCoverFromM4A() that reads the covr atom from the MP4
box tree (moov/udta/meta/ilst/covr/data). Wire it into
ExtractCoverToFile so ALAC-to-FLAC conversion preserves cover art.
2026-03-16 20:35:58 +07:00
zarzet 280b921755 fix: detect embedded lyrics in M4A/ALAC files
Add extractLyricsFromM4A() that walks the MP4 box tree
(moov/udta/meta/ilst/©lyr) to read lyrics. Wire it into
ExtractLyrics so the Embed Lyrics button is hidden when
lyrics already exist in the file.
2026-03-16 20:35:58 +07:00
zarzet 6ebe0c51ce fix: filter batch convert target formats based on source formats
Exclude same-format and lossy-to-lossless targets from the batch
convert sheet so users cannot pick pointless conversions like
FLAC→FLAC. Also clean up redundant inline comments.
2026-03-16 20:35:58 +07:00
zarzet 47bd24c1bd fix: preserve metadata and cover art in ALAC/M4A to FLAC conversion
- Use -map_metadata 0 instead of -map_metadata -1 so FFmpeg copies and
  auto-remaps source tags (M4A/ID3 → Vorbis comments) as a base
- Add _normalizeToVorbisComments() to filter technical fields (BIT_DEPTH,
  SAMPLE_RATE, DURATION) and normalize key variations to standard Vorbis
  comment names before applying overrides
- Switch cover art embedding from METADATA_BLOCK_PICTURE base64 (unreliable
  on Android due to command-line length limits) to -i cover -map 1:v
  -disposition attached_pic (same proven approach as embedMetadata and
  _convertToAlac)
- Drop zero-value track/disc numbers from override map to prevent
  clobbering source metadata with '0' from Go readFileMetadata
2026-03-16 20:35:58 +07:00
zarzet 2b23678c0d feat: add FLAC/ALAC bidirectional lossless conversion support
- Add _convertToAlac() and _convertToFlac() in ffmpeg_service with
  single-pass FFmpeg encoding, metadata tags, and cover art embedding
- Wire lossless formats (ALAC, FLAC) into single-track convert sheet
  with dynamic format list based on source format, hidden bitrate for
  lossless targets, and lossless hint text
- Add lossless conversion to batch convert UI in downloaded_album,
  local_album, and queue_tab screens with lossy-source filtering
- Fix M4A quality probe in Go backend: increase audio sample entry
  buffer from 24 to 32 bytes, read sample rate from correct offset
  (bytes 28-29) and bit depth from samplesize field (bytes 22-23)
- Add l10n keys for lossless confirm dialogs and hints (en, id)
2026-03-16 20:35:58 +07:00
zarzet e8327545ad feat: improve auto-fill track resolution in Edit Metadata sheet
- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
2026-03-16 20:35:58 +07:00
zarzet 89a38af538 fix: resolve all flutter analyze warnings and improve auto-fill enrichment chain
- Fix use_build_context_synchronously in _embedLyrics by capturing l10n
  strings before async gaps (snackbarFailedToWriteStorage,
  snackbarFailedToEmbedLyrics, snackbarUnsupportedAudioFormat)
- Improve auto-fill metadata enrichment to use proper API chain:
  search providers -> convertSpotifyToDeezer (SongLink) for Deezer ID
  -> getDeezerMetadata for ISRC -> getDeezerExtendedMetadata for
  genre/label/copyright. Falls back to ISRC-based Deezer lookup when
  SongLink conversion unavailable.
- flutter analyze now reports 0 issues
2026-03-16 20:35:58 +07:00
zarzet b7f34ec47c feat: selective auto-fill from online in Edit Metadata sheet
Add 'Auto-fill from online' expandable section to the metadata editor
that lets users choose exactly which fields to populate from online
metadata search. Users can select individual fields via filter chips,
use 'All' or 'Empty only' quick-select buttons, then tap 'Fetch & Fill'
to search metadata providers and fill only the selected controllers.

The search uses existing searchTracksWithMetadataProviders API with
ISRC-preferring best-match selection. Extended metadata (genre, label,
copyright) is fetched via Deezer extended metadata API when available.
Cover art is downloaded from the match's cover_url. All results are
previewed in the editor before saving — nothing is written to the file
until the user taps Save.

Add 21 new l10n keys (editMetadata* namespace) for all UI strings.
2026-03-16 20:35:58 +07:00
zarzet 967523bfc6 feat: queue FLAC redownloads for local library tracks
Add LocalTrackRedownloadService with confidence-scored metadata matching
(ISRC, title, artist, album, duration, track/disc number, year) to find
reliable online matches for locally-stored tracks.

Wire up 'Queue FLAC' selection action in both local_album_screen and
queue_tab (library tab). Shows progress snackbar during resolution,
skips ambiguous or low-confidence matches, and reports results.

Add Indonesian (id) translations for all queueFlac l10n keys.
2026-03-16 20:35:58 +07:00
zarzet 29d8a185f9 fix: handle nested legacy iOS Documents path in validation
Detect and recover from stale sandbox container paths embedded inside
the current Documents directory. Extracts helper functions for path
suffix normalization and joining to reduce duplication.
2026-03-16 20:35:57 +07:00
zarzet 4495d4bf4e feat: add Opus 320kbps quality, remove Tidal HIGH tier
- Add YouTubeQualityOpus320 constant and opus_320 parser case in Go backend
- Expand opus supported bitrates to [128, 256, 320] across Go, Dart settings, and UI
- Update default YouTube Opus option from 256 to 320kbps
- Remove Tidal HIGH (lossy 320kbps) quality from Go backend, settings model,
  settings provider, download queue provider (both SAF and non-SAF paths),
  settings UI (quality option, format picker, helper methods), and l10n keys
- Add settings migration v6: auto-migrate users with audioQuality=HIGH to LOSSLESS
- Update and add Go test cases for opus_320 and adjusted max bitrate
- Regenerate l10n files, remove 10 unused downloadLossy* l10n keys
2026-03-16 20:35:57 +07:00
zarzet 67737467e0 ci: auto-update AltStore source (apps.json) on release 2026-03-16 20:35:57 +07:00
renovate[bot] 13845eea04 chore(deps): update dependency flutter to v3.41.4 2026-03-16 20:35:57 +07:00
zarzet 12779778d3 fix(i18n): localize hardcoded strings in bulk playlist download and fix trailing newlines 2026-03-16 20:35:57 +07:00
ViscousPot d4178ad036 feat: add option to download multiple selected playlists 2026-03-16 20:35:57 +07:00
ViscousPot 49ea84384d feat: auto fill playlist name during import 2026-03-16 20:35:57 +07:00
ViscousPot a6d9849468 Update CONTRIBUTING.md 2026-03-16 20:35:57 +07:00
ViscousPot 16100aa0fd add fvm 2026-03-16 20:35:57 +07:00
zarzet 387dd47374 feat: add Qobuz Afkar API provider and prefer request metadata for consistent album grouping 2026-03-16 20:35:57 +07:00
github-actions[bot] f67f52eba9 chore: update AltStore source to v3.8.5 2026-03-15 21:35:25 +00:00
zarzet 18607597e9 fix: correct AltStore icon URL to assets/images/logo.png 2026-03-15 19:41:25 +07:00
Zarz Eleutherius 78cd396847 Merge pull request #233 from Amonoman/main
Add AltStore source and update README documentation
2026-03-15 19:07:50 +07:00
Amonoman 8540da484f Add AltStore source and update README 2026-03-15 13:02:23 +01:00
zarzet 8c18c7b8f1 Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-03-14 23:12:26 +07:00
zarzet 10c5293f64 chore: update VirusTotal hash for v3.8.0 2026-03-14 23:10:19 +07:00
Zarz Eleutherius c347b6999e Merge pull request #218 from ViscousPot/fix/folder-organization-by-playlist-for-library-playlists
Reviewed and approved. The fix correctly passes playlistName to addMultipleToQueue() for library playlists, consistent with playlist_screen.dart pattern.
2026-03-14 12:57:32 +07:00
ViscousPot adc74741ce Update library_tracks_folder_screen.dart 2026-03-14 01:48:34 +00:00
54 changed files with 5910 additions and 1412 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.4"
}
+57
View File
@@ -393,6 +393,63 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-altstore:
runs-on: ubuntu-latest
needs: [get-version, build-ios, create-release]
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
permissions:
contents: write
steps:
- name: Checkout main branch
uses: actions/checkout@v6
with:
ref: main
- name: Download iOS IPA
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
- name: Update apps.json
run: |
VERSION="${{ needs.get-version.outputs.version }}"
VERSION_NUM="${VERSION#v}"
DATE=$(date -u +%Y-%m-%d)
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
if [ -z "$IPA_FILE" ]; then
echo "WARNING: IPA file not found, skipping apps.json update"
exit 0
fi
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
if [ ! -f apps.json ]; then
echo "WARNING: apps.json not found on main, skipping"
exit 0
fi
jq --arg ver "$VERSION_NUM" \
--arg date "$DATE" \
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
--argjson size "$IPA_SIZE" \
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
echo "Updated apps.json:"
cat apps.json
- name: Commit and push
run: |
VERSION="${{ needs.get-version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps.json
git diff --cached --quiet && echo "No changes to commit" || \
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
notify-telegram:
runs-on: ubuntu-latest
needs: [get-version, create-release]
+3
View File
@@ -77,3 +77,6 @@ flutter_*.log
# Development tools
tool/
.claude/settings.local.json
# FVM Version Cache
.fvm/
+17 -3
View File
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
```
3. **Install dependencies**
3. **Use FVM (Flutter Version: 3.38.1)**
```bash
fvm use
```
4. **Install dependencies**
```bash
flutter pub get
```
4. **Generate code** (for Riverpod, JSON serialization, etc.)
5. **Generate code** (for Riverpod, JSON serialization, etc.)
```bash
dart run build_runner build --delete-conflicting-outputs
```
5. **Run the app**
6. **Set up Go environment (Go Version: 1.25.7)**
```bash
cd go_backend
mkdir -p ../android/app/libs
gomobile init
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
cd ..
```
7. **Run the app**
```bash
flutter run
```
+7 -2
View File
@@ -26,7 +26,7 @@
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
@@ -73,6 +73,11 @@ A: Yes, the app is open source and you can verify the code yourself. Each releas
**Q: Why is download not working in my country?**
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
**Q: Can I add SpotiFLAC to AltStore or SideStore?**
A: Yes! You can add the official source to receive updates directly within the app. Just copy this link:
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
In AltStore/SideStore, go to the Browse tab, tap Sources at the top, then tap the + icon and paste the link.
### Want to support SpotiFLAC-Mobile?
@@ -87,4 +92,4 @@ _If this software is useful and brings you value, consider supporting the projec
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
> **Star Us**, You will receive all release notifications from GitHub without any delay
+18
View File
@@ -0,0 +1,18 @@
{
"name": "SpotiFLAC Source",
"identifier": "com.zarzet.spotiflac.source",
"subtitle": "FLAC Downloader for iOS",
"apps": [
{
"name": "SpotiFLAC",
"bundleIdentifier": "com.zarzet.spotiflac",
"developerName": "zarzet",
"version": "3.8.5",
"versionDate": "2026-03-15",
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.5/SpotiFLAC-v3.8.5-ios-unsigned.ipa",
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
"size": 33673615
}
]
}
+8 -5
View File
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
NbTracks int `json:"nb_tracks"`
RecordType string `json:"record_type"`
Label string `json:"label"`
Copyright string `json:"copyright"`
Genres struct {
Data []deezerGenre `json:"data"`
} `json:"genres"`
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
}
type AlbumExtendedMetadata struct {
Genre string
Label string
Genre string
Label string
Copyright string
}
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
@@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
}
result := &AlbumExtendedMetadata{
Genre: strings.Join(genres, ", "),
Label: album.Label,
Genre: strings.Join(genres, ", "),
Label: album.Label,
Copyright: album.Copyright,
}
c.cacheMu.Lock()
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
return result, nil
}
+106 -30
View File
@@ -135,6 +135,36 @@ type DownloadResult struct {
DecryptionKey string
}
func preferredReleaseMetadata(
req DownloadRequest,
album string,
releaseDate string,
trackNumber int,
discNumber int,
) (string, string, int, int) {
preferredAlbum := strings.TrimSpace(req.AlbumName)
if preferredAlbum == "" {
preferredAlbum = album
}
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
if preferredReleaseDate == "" {
preferredReleaseDate = releaseDate
}
preferredTrackNumber := req.TrackNumber
if preferredTrackNumber == 0 {
preferredTrackNumber = trackNumber
}
preferredDiscNumber := req.DiscNumber
if preferredDiscNumber == 0 {
preferredDiscNumber = discNumber
}
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
}
func buildDownloadSuccessResponse(
req DownloadRequest,
result DownloadResult,
@@ -153,25 +183,16 @@ func buildDownloadSuccessResponse(
artist = req.ArtistName
}
album := result.Album
if album == "" {
album = req.AlbumName
}
releaseDate := result.ReleaseDate
if releaseDate == "" {
releaseDate = req.ReleaseDate
}
trackNumber := result.TrackNumber
if trackNumber == 0 {
trackNumber = req.TrackNumber
}
discNumber := result.DiscNumber
if discNumber == 0 {
discNumber = req.DiscNumber
}
// Preserve requested release metadata when available so mixed-provider
// fallback downloads from the same source album do not get split into
// different albums just because Tidal/Qobuz report variant titles/dates.
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
req,
result.Album,
result.ReleaseDate,
result.TrackNumber,
result.DiscNumber,
)
isrc := result.ISRC
if isrc == "" {
@@ -262,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
return
}
@@ -284,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Genre != "" || req.Label != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -1314,10 +1338,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) {
return "", err
}
result := map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
}
result := buildDeezerExtendedMetadataResult(metadata)
jsonBytes, err := json.Marshal(result)
if err != nil {
@@ -1337,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return "", err
}
jsonBytes, err := json.Marshal(track)
result := buildDeezerISRCSearchResult(track)
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
@@ -1345,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
return string(jsonBytes), nil
}
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
if metadata == nil {
return map[string]string{
"genre": "",
"label": "",
"copyright": "",
}
}
return map[string]string{
"genre": metadata.Genre,
"label": metadata.Label,
"copyright": metadata.Copyright,
}
}
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} {
if track == nil {
return map[string]interface{}{}
}
result := map[string]interface{}{
"spotify_id": track.SpotifyID,
"artists": track.Artists,
"name": track.Name,
"album_name": track.AlbumName,
"album_artist": track.AlbumArtist,
"duration_ms": track.DurationMS,
"images": track.Images,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"total_tracks": track.TotalTracks,
"disc_number": track.DiscNumber,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
}
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
result["id"] = deezerID
result["track_id"] = deezerID
result["success"] = true
}
return result
}
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -1623,6 +1694,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
coverData, err = extractCoverFromM4A(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
@@ -1803,8 +1876,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
// Try to get extended metadata from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
@@ -1815,7 +1888,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
@@ -0,0 +1,59 @@
package gobackend
import "testing"
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
result := buildDeezerExtendedMetadataResult(nil)
if result["genre"] != "" {
t.Fatalf("expected empty genre, got %q", result["genre"])
}
if result["label"] != "" {
t.Fatalf("expected empty label, got %q", result["label"])
}
if result["copyright"] != "" {
t.Fatalf("expected empty copyright, got %q", result["copyright"])
}
}
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
Genre: "Rock",
Label: "EMI",
Copyright: "(C) Queen",
})
if result["genre"] != "Rock" {
t.Fatalf("unexpected genre: %q", result["genre"])
}
if result["label"] != "EMI" {
t.Fatalf("unexpected label: %q", result["label"])
}
if result["copyright"] != "(C) Queen" {
t.Fatalf("unexpected copyright: %q", result["copyright"])
}
}
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
result := buildDeezerISRCSearchResult(&TrackMetadata{
SpotifyID: "deezer:3135556",
Name: "Love Of My Life",
Artists: "Queen",
AlbumName: "A Night at the Opera",
ISRC: "GBUM71029604",
ReleaseDate: "1975-11-21",
})
if result["spotify_id"] != "deezer:3135556" {
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
}
if result["id"] != "3135556" {
t.Fatalf("unexpected id: %v", result["id"])
}
if result["track_id"] != "3135556" {
t.Fatalf("unexpected track_id: %v", result["track_id"])
}
if result["success"] != true {
t.Fatalf("expected success=true, got %v", result["success"])
}
}
+86
View File
@@ -0,0 +1,86 @@
package gobackend
import "testing"
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
req := DownloadRequest{
TrackName: "Bonus Track",
ArtistName: "Artist",
AlbumName: "Album (Deluxe)",
AlbumArtist: "Artist",
ReleaseDate: "2024-01-01",
TrackNumber: 14,
DiscNumber: 1,
ISRC: "REQ123",
CoverURL: "https://example.com/cover.jpg",
Genre: "Pop",
Label: "Label",
Copyright: "Copyright",
}
result := DownloadResult{
Title: "Bonus Track",
Artist: "Artist",
Album: "Album",
ReleaseDate: "2023-12-01",
TrackNumber: 2,
DiscNumber: 9,
ISRC: "RES456",
}
resp := buildDownloadSuccessResponse(
req,
result,
"tidal",
"ok",
"/tmp/test.flac",
false,
)
if resp.Album != req.AlbumName {
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
}
if resp.ReleaseDate != req.ReleaseDate {
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
}
if resp.TrackNumber != req.TrackNumber {
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
}
if resp.DiscNumber != req.DiscNumber {
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
}
if resp.Artist != result.Artist {
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
}
if resp.ISRC != result.ISRC {
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
}
}
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
DownloadRequest{
AlbumName: "Album (Deluxe Edition)",
ReleaseDate: "2024-01-01",
TrackNumber: 13,
DiscNumber: 2,
},
"Album",
"2023-01-01",
3,
1,
)
if album != "Album (Deluxe Edition)" {
t.Fatalf("album = %q", album)
}
if releaseDate != "2024-01-01" {
t.Fatalf("release date = %q", releaseDate)
}
if trackNumber != 13 {
t.Fatalf("track number = %d", trackNumber)
}
if discNumber != 2 {
t.Fatalf("disc number = %d", discNumber)
}
}
+13 -4
View File
@@ -1065,8 +1065,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
}
// Try Deezer extended metadata for genre/label if we have ISRC
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
// Try Deezer extended metadata if we have ISRC
if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
@@ -1077,7 +1078,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label)
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
}
@@ -1249,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
if isBuiltInProvider(providerIDNormalized) {
if (req.Genre == "" || req.Label == "") && req.ISRC != "" {
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
req.ISRC != "" {
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
deezerClient := GetDeezerClient()
@@ -1264,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
req.Label = extMeta.Label
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label)
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright)
}
} else if err != nil {
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
+174 -6
View File
@@ -552,6 +552,14 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
lyrics, err := extractLyricsFromM4A(filePath)
if err == nil && strings.TrimSpace(lyrics) != "" {
return lyrics, nil
}
return extractLyricsFromSidecarLRC(filePath)
}
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
@@ -581,6 +589,153 @@ func ExtractLyrics(filePath string) (string, error) {
return extractLyricsFromSidecarLRC(filePath)
}
func extractLyricsFromM4A(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return "", err
}
fileSize := fi.Size()
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return "", fmt.Errorf("moov not found")
}
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) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, err
}
fileSize := fi.Size()
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("moov not found")
}
bodyStart := moov.offset + moov.headerSize
bodySize := moov.size - moov.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)
if err != nil || !found {
return nil, fmt.Errorf("cover atom not found")
}
dataStart := covr.offset + covr.headerSize
dataSize := covr.size - covr.headerSize
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
if err != nil || !found {
return nil, fmt.Errorf("data atom not found in cover")
}
// data atom: header + 4 bytes type indicator + 4 bytes locale
imgStart := dataAtom.offset + dataAtom.headerSize + 8
imgLen := dataAtom.size - dataAtom.headerSize - 8
if imgLen <= 0 {
return nil, fmt.Errorf("empty cover data")
}
buf := make([]byte, imgLen)
if _, err := f.ReadAt(buf, imgStart); err != nil {
return nil, err
}
return buf, nil
}
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
ext := filepath.Ext(filePath)
base := strings.TrimSuffix(filePath, ext)
@@ -743,15 +898,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, err
}
buf := make([]byte, 24)
buf := make([]byte, 32)
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
}
sampleRate := int(buf[22])<<8 | int(buf[23])
bitDepth := 16
if atomType == "alac" {
bitDepth = 24
// AudioSampleEntry layout from the box type field:
// [0:4] type ("mp4a"/"alac")
// [4:10] SampleEntry.reserved
// [10:12] data_reference_index
// [12:20] reserved[8]
// [20:22] channelcount
// [22:24] samplesize (bit depth)
// [24:26] pre_defined
// [26:28] reserved
// [28:32] samplerate (16.16 fixed-point)
sampleRate := int(buf[28])<<8 | int(buf[29])
bitDepth := int(buf[22])<<8 | int(buf[23])
if bitDepth <= 0 {
bitDepth = 16
if atomType == "alac" {
bitDepth = 24
}
}
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
@@ -874,7 +1042,7 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
if bestIdx >= 0 {
absolute := readPos - int64(len(tail)) + int64(bestIdx)
if absolute+24 > fileSize {
if absolute+32 > fileSize {
return 0, "", fmt.Errorf("audio info not found in M4A file")
}
return absolute, bestType, nil
+24 -5
View File
@@ -54,6 +54,7 @@ const (
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.fun/api/track/"
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
)
@@ -1019,6 +1020,10 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
func (q *QobuzDownloader) GetAvailableAPIs() []string {
return []string{
qobuzDownloadAPIURL,
qobuzDabMusicAPIURL,
qobuzDeebAPIURL,
qobuzAfkarAPIURL,
qobuzSquidAPIURL,
}
}
@@ -1039,6 +1044,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
// "qbz" comes from the desktop reference app and uses /api/track/{id}?quality=...
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
}
}
@@ -2156,6 +2163,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if req.AlbumName != "" {
albumName = req.AlbumName
}
releaseDate := track.Album.ReleaseDate
if req.ReleaseDate != "" {
releaseDate = req.ReleaseDate
}
actualTrackNumber := req.TrackNumber
if actualTrackNumber == 0 {
@@ -2167,7 +2178,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist,
Date: track.Album.ReleaseDate,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
@@ -2231,16 +2242,24 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
req.DiscNumber,
)
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
SampleRate: actualSampleRate,
Title: track.Title,
Artist: track.Performer.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
+4 -2
View File
@@ -213,14 +213,16 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
func TestQobuzAvailableProviders(t *testing.T) {
providers := NewQobuzDownloader().GetAvailableProviders()
if len(providers) != 3 {
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
if len(providers) != 5 {
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
}
want := map[string]string{
"musicdl": qobuzAPIKindMusicDL,
"dabmusic": qobuzAPIKindStandard,
"deeb": qobuzAPIKindStandard,
"qbz": qobuzAPIKindStandard,
"squid": qobuzAPIKindStandard,
}
for _, provider := range providers {
+17 -39
View File
@@ -1961,11 +1961,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
if quality == "HIGH" {
outputExt = ".m4a"
} else {
outputExt = ".flac"
}
outputExt = ".flac"
} else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt
}
@@ -1979,7 +1975,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
m4aPath = outputPath
} else {
if outputExt == ".m4a" || quality == "HIGH" {
if outputExt == ".m4a" {
filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath
@@ -1992,10 +1988,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
}
@@ -2151,27 +2145,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
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)")
}
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
}
if !isSafOutput {
@@ -2181,24 +2155,28 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
lyricsLRC := ""
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
}
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
req,
track.Album.Title,
track.Album.ReleaseDate,
actualTrackNumber,
actualDiscNumber,
)
return TidalDownloadResult{
FilePath: actualOutputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: track.Title,
Artist: track.Artist.Name,
Album: track.Album.Title,
ReleaseDate: track.Album.ReleaseDate,
TrackNumber: actualTrackNumber,
DiscNumber: actualDiscNumber,
Album: resultAlbum,
ReleaseDate: resultReleaseDate,
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
+4 -1
View File
@@ -29,6 +29,7 @@ var (
type YouTubeQuality string
const (
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
@@ -37,7 +38,7 @@ const (
)
var (
youtubeOpusSupportedBitrates = []int{128, 256}
youtubeOpusSupportedBitrates = []int{128, 256, 320}
youtubeMp3SupportedBitrates = []int{128, 256, 320}
)
@@ -146,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
switch normalizedRaw {
case "opus_256", "opus256", "opus":
return "opus", 256, YouTubeQualityOpus256
case "opus_320", "opus320":
return "opus", 320, YouTubeQualityOpus320
case "opus_128", "opus128":
return "opus", 128, YouTubeQualityOpus128
case "mp3_320", "mp3320", "mp3", "":
+15 -2
View File
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
if opusBitrate != 256 {
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
if opusBitrate != 320 {
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
}
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
}
}
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
if format != "opus" {
t.Fatalf("expected opus format, got %s", format)
}
if bitrate != 320 {
t.Fatalf("expected 320 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityOpus320 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.8.0';
static const String buildNumber = '106';
static const String version = '3.8.5';
static const String buildNumber = '111';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
+215 -68
View File
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
/// **'Import'**
String get dialogImport;
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// Dialog button - discard changes
///
/// In en, this message translates to:
@@ -3832,6 +3838,36 @@ abstract class AppLocalizations {
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
/// Action/button label for queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Queue FLAC'**
String get queueFlacAction;
/// Confirmation dialog body before queueing FLAC redownloads for local tracks
///
/// In en, this message translates to:
/// **'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected'**
String queueFlacConfirmMessage(int count);
/// Snackbar while resolving remote matches for local FLAC redownloads
///
/// In en, this message translates to:
/// **'Finding FLAC matches... ({current}/{total})'**
String queueFlacFindingProgress(int current, int total);
/// Snackbar when no safe FLAC redownload matches were found
///
/// In en, this message translates to:
/// **'No reliable online matches found for the selection'**
String get queueFlacNoReliableMatches;
/// Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped
///
/// In en, this message translates to:
/// **'Added {addedCount} tracks to queue, skipped {skippedCount}'**
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount);
/// Snackbar when save operation fails
///
/// In en, this message translates to:
@@ -3847,7 +3883,7 @@ abstract class AppLocalizations {
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
@@ -3884,6 +3920,21 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless-to-lossless conversion
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
);
/// Hint shown when converting between lossless formats
///
/// In en, this message translates to:
/// **'Lossless conversion — no quality loss'**
String get trackConvertLosslessHint;
/// Snackbar while converting
///
/// In en, this message translates to:
@@ -4254,6 +4305,12 @@ abstract class AppLocalizations {
String bitrate,
);
/// Confirmation dialog message for lossless batch conversion
///
/// In en, this message translates to:
/// **'Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.'**
String selectionBatchConvertConfirmMessageLossless(int count, String format);
/// Snackbar during batch conversion progress
///
/// In en, this message translates to:
@@ -4434,7 +4491,7 @@ abstract class AppLocalizations {
/// **'Added {count} tracks to Loved'**
String snackbarAddedTracksToLoved(int count);
/// Title of the Download All confirmation dialog
/// Dialog title for bulk download confirmation
///
/// In en, this message translates to:
/// **'Download All'**
@@ -4446,12 +4503,6 @@ abstract class AppLocalizations {
/// **'Download {count} tracks?'**
String dialogDownloadAllMessage(int count);
/// Confirm button in Download All dialog
///
/// In en, this message translates to:
/// **'Download'**
String get dialogDownload;
/// Checkbox label in import dialog to skip already-downloaded songs
///
/// In en, this message translates to:
@@ -4602,18 +4653,6 @@ abstract class AppLocalizations {
/// **'Select a built-in service to enable'**
String get downloadSelectServiceToEnable;
/// 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;
/// Info hint when non-Tidal/Qobuz service is selected
///
/// In en, this message translates to:
@@ -4740,54 +4779,6 @@ abstract class AppLocalizations {
/// **'Auto'**
String get downloadMusixmatchAuto;
/// 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 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 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 option
///
/// In en, this message translates to:
/// **'Smallest size, ~4MB per track'**
String get downloadLossyOpus128Subtitle;
/// Subtitle for 'Any' network mode option
///
/// In en, this message translates to:
@@ -4817,6 +4808,162 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Refresh'**
String get cacheRefresh;
/// Dialog message for bulk playlist download confirmation
///
/// In en, this message translates to:
/// **'Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?'**
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount);
/// Button label for bulk downloading selected playlists
///
/// In en, this message translates to:
/// **'Download {count} {count, plural, =1{playlist} other{playlists}}'**
String bulkDownloadPlaylistsButton(int count);
/// Button label when no playlists are selected for download
///
/// In en, this message translates to:
/// **'Select playlists to download'**
String get bulkDownloadSelectPlaylists;
/// Snackbar when selected playlists contain no tracks
///
/// In en, this message translates to:
/// **'Selected playlists have no tracks'**
String get snackbarSelectedPlaylistsEmpty;
/// Playlist count display
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 playlist} other{{count} playlists}}'**
String playlistsCount(int count);
/// Section title for selective online metadata auto-fill in the edit metadata sheet
///
/// In en, this message translates to:
/// **'Auto-fill from online'**
String get editMetadataAutoFill;
/// Description for the auto-fill section
///
/// In en, this message translates to:
/// **'Select fields to fill automatically from online metadata'**
String get editMetadataAutoFillDesc;
/// Button label to fetch online metadata and fill selected fields
///
/// In en, this message translates to:
/// **'Fetch & Fill'**
String get editMetadataAutoFillFetch;
/// Snackbar shown while searching for online metadata
///
/// In en, this message translates to:
/// **'Searching online...'**
String get editMetadataAutoFillSearching;
/// Snackbar when online metadata search returns no results
///
/// In en, this message translates to:
/// **'No matching metadata found online'**
String get editMetadataAutoFillNoResults;
/// Snackbar confirming how many fields were auto-filled
///
/// In en, this message translates to:
/// **'Filled {count} {count, plural, =1{field} other{fields}} from online metadata'**
String editMetadataAutoFillDone(int count);
/// Snackbar when user taps Fetch without selecting any fields
///
/// In en, this message translates to:
/// **'Select at least one field to auto-fill'**
String get editMetadataAutoFillNoneSelected;
/// Chip label for title field in auto-fill selector
///
/// In en, this message translates to:
/// **'Title'**
String get editMetadataFieldTitle;
/// Chip label for artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Artist'**
String get editMetadataFieldArtist;
/// Chip label for album field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album'**
String get editMetadataFieldAlbum;
/// Chip label for album artist field in auto-fill selector
///
/// In en, this message translates to:
/// **'Album Artist'**
String get editMetadataFieldAlbumArtist;
/// Chip label for date field in auto-fill selector
///
/// In en, this message translates to:
/// **'Date'**
String get editMetadataFieldDate;
/// Chip label for track number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Track #'**
String get editMetadataFieldTrackNum;
/// Chip label for disc number field in auto-fill selector
///
/// In en, this message translates to:
/// **'Disc #'**
String get editMetadataFieldDiscNum;
/// Chip label for genre field in auto-fill selector
///
/// In en, this message translates to:
/// **'Genre'**
String get editMetadataFieldGenre;
/// Chip label for ISRC field in auto-fill selector
///
/// In en, this message translates to:
/// **'ISRC'**
String get editMetadataFieldIsrc;
/// Chip label for label field in auto-fill selector
///
/// In en, this message translates to:
/// **'Label'**
String get editMetadataFieldLabel;
/// Chip label for copyright field in auto-fill selector
///
/// In en, this message translates to:
/// **'Copyright'**
String get editMetadataFieldCopyright;
/// Chip label for cover art field in auto-fill selector
///
/// In en, this message translates to:
/// **'Cover Art'**
String get editMetadataFieldCover;
/// Button to select all fields for auto-fill
///
/// In en, this message translates to:
/// **'All'**
String get editMetadataSelectAll;
/// Button to select only fields that are currently empty
///
/// In en, this message translates to:
/// **'Empty only'**
String get editMetadataSelectEmpty;
}
class _AppLocalizationsDelegate
+168 -35
View File
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dialogImport => 'Importieren';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Verwerfen';
@@ -2169,6 +2172,28 @@ class AppLocalizationsDe extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Fehler: $error';
@@ -2201,6 +2226,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Konvertiere Audio...';
@@ -2455,6 +2492,17 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Konvertiere $current von $total...';
@@ -2580,9 +2628,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2666,12 +2711,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2748,32 +2787,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2790,4 +2803,124 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+170 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -2142,6 +2145,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2176,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2200,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2465,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2601,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2684,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2760,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2776,124 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+170 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -2142,6 +2145,28 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2176,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2200,18 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2465,17 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2601,6 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2684,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2760,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,6 +2776,126 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+168 -35
View File
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -2144,6 +2147,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2176,6 +2201,18 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2429,6 +2466,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2554,9 +2602,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2640,12 +2685,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2722,32 +2761,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2764,4 +2777,124 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+168 -35
View File
@@ -525,6 +525,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -2142,6 +2145,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2174,6 +2199,18 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2464,17 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2600,6 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2683,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2759,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2775,124 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+170 -36
View File
@@ -528,6 +528,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get dialogImport => 'Impor';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Buang';
@@ -2149,6 +2152,28 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Antrekan FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Mencari kecocokan FLAC... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2158,7 +2183,8 @@ class AppLocalizationsId extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Konversi ke MP3, Opus, ALAC, atau FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2181,6 +2207,18 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertLosslessHint =>
'Konversi lossless — tanpa kehilangan kualitas';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2434,6 +2472,17 @@ class AppLocalizationsId extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2559,9 +2608,6 @@ class AppLocalizationsId extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2645,12 +2691,6 @@ class AppLocalizationsId extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2727,32 +2767,6 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2769,4 +2783,124 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+168 -35
View File
@@ -521,6 +521,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get dialogImport => 'インポート';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '破棄';
@@ -2129,6 +2132,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return '失敗: $error';
@@ -2161,6 +2186,18 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'オーディオを変換中...';
@@ -2414,6 +2451,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2539,9 +2587,6 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2625,12 +2670,6 @@ class AppLocalizationsJa extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2707,32 +2746,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2749,4 +2762,124 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+168 -35
View File
@@ -510,6 +510,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get dialogImport => '불러오기';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => '취소';
@@ -2122,6 +2125,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2154,6 +2179,18 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2407,6 +2444,17 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2532,9 +2580,6 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2618,12 +2663,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2700,32 +2739,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2742,4 +2755,124 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+168 -35
View File
@@ -525,6 +525,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -2142,6 +2145,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2174,6 +2199,18 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2464,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2600,6 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2683,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2759,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,4 +2775,124 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+170 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -2142,6 +2145,28 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2176,8 @@ class AppLocalizationsPt extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2200,18 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2465,17 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2601,6 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2684,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2760,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,6 +2776,126 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
+168 -35
View File
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get dialogImport => 'Импорт';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Отменить';
@@ -2195,6 +2198,28 @@ class AppLocalizationsRu extends AppLocalizations {
String get trackReEnrichFfmpegFailed =>
'Ошибка встраивания метаданных FFmpeg';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Ошибка: $error';
@@ -2227,6 +2252,18 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Конвертация аудио...';
@@ -2486,6 +2523,17 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Конвертация $current из $total...';
@@ -2611,9 +2659,6 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2697,12 +2742,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2779,32 +2818,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2821,4 +2834,124 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+168 -35
View File
@@ -530,6 +530,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get dialogImport => 'İçe aktar';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Vazgeç';
@@ -2154,6 +2157,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2186,6 +2211,18 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2439,6 +2476,17 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2564,9 +2612,6 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2650,12 +2695,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2732,32 +2771,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2774,4 +2787,124 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
+170 -36
View File
@@ -525,6 +525,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get dialogImport => 'Import';
@override
String get dialogDownload => 'Download';
@override
String get dialogDiscard => 'Discard';
@@ -2142,6 +2145,28 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String get queueFlacAction => 'Queue FLAC';
@override
String queueFlacConfirmMessage(int count) {
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
}
@override
String queueFlacFindingProgress(int current, int total) {
return 'Finding FLAC matches... ($current/$total)';
}
@override
String get queueFlacNoReliableMatches =>
'No reliable online matches found for the selection';
@override
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
return 'Added $addedCount tracks to queue, skipped $skippedCount';
}
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
@@ -2151,7 +2176,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
String get trackConvertFormatSubtitle =>
'Convert to MP3, Opus, ALAC, or FLAC';
@override
String get trackConvertTitle => 'Convert Audio';
@@ -2174,6 +2200,18 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String trackConvertConfirmMessageLossless(
String sourceFormat,
String targetFormat,
) {
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertLosslessHint =>
'Lossless conversion — no quality loss';
@override
String get trackConvertConverting => 'Converting audio...';
@@ -2427,6 +2465,17 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertConfirmMessageLossless(int count, String format) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'tracks',
one: 'track',
);
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
}
@override
String selectionBatchConvertProgress(int current, int total) {
return 'Converting $current of $total...';
@@ -2552,9 +2601,6 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Download $count tracks?';
}
@override
String get dialogDownload => 'Download';
@override
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
@@ -2638,12 +2684,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get downloadSelectServiceToEnable =>
'Select a built-in service to enable';
@override
String get downloadLossy320 => 'Lossy 320kbps';
@override
String get downloadLossyFormat => 'Lossy Format';
@override
String get downloadSelectTidalQobuz =>
'Select Tidal or Qobuz above to configure quality';
@@ -2720,32 +2760,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadMusixmatchAuto => 'Auto';
@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
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
@@ -2762,6 +2776,126 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get cacheRefresh => 'Refresh';
@override
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
String _temp0 = intl.Intl.pluralLogic(
trackCount,
locale: localeName,
other: 'tracks',
one: 'track',
);
String _temp1 = intl.Intl.pluralLogic(
playlistCount,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
}
@override
String bulkDownloadPlaylistsButton(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'playlists',
one: 'playlist',
);
return 'Download $count $_temp0';
}
@override
String get bulkDownloadSelectPlaylists => 'Select playlists to download';
@override
String get snackbarSelectedPlaylistsEmpty =>
'Selected playlists have no tracks';
@override
String playlistsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count playlists',
one: '1 playlist',
);
return '$_temp0';
}
@override
String get editMetadataAutoFill => 'Auto-fill from online';
@override
String get editMetadataAutoFillDesc =>
'Select fields to fill automatically from online metadata';
@override
String get editMetadataAutoFillFetch => 'Fetch & Fill';
@override
String get editMetadataAutoFillSearching => 'Searching online...';
@override
String get editMetadataAutoFillNoResults =>
'No matching metadata found online';
@override
String editMetadataAutoFillDone(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'fields',
one: 'field',
);
return 'Filled $count $_temp0 from online metadata';
}
@override
String get editMetadataAutoFillNoneSelected =>
'Select at least one field to auto-fill';
@override
String get editMetadataFieldTitle => 'Title';
@override
String get editMetadataFieldArtist => 'Artist';
@override
String get editMetadataFieldAlbum => 'Album';
@override
String get editMetadataFieldAlbumArtist => 'Album Artist';
@override
String get editMetadataFieldDate => 'Date';
@override
String get editMetadataFieldTrackNum => 'Track #';
@override
String get editMetadataFieldDiscNum => 'Disc #';
@override
String get editMetadataFieldGenre => 'Genre';
@override
String get editMetadataFieldIsrc => 'ISRC';
@override
String get editMetadataFieldLabel => 'Label';
@override
String get editMetadataFieldCopyright => 'Copyright';
@override
String get editMetadataFieldCover => 'Cover Art';
@override
String get editMetadataSelectAll => 'All';
@override
String get editMetadataSelectEmpty => 'Empty only';
}
/// The translations for Chinese, as used in China (`zh_CN`).
+207 -41
View File
@@ -671,6 +671,10 @@
"@dialogImport": {
"description": "Dialog button - import data"
},
"dialogDownload": "Download",
"@dialogDownload": {
"description": "Dialog button - download action"
},
"dialogDiscard": "Discard",
"@dialogDiscard": {
"description": "Dialog button - discard changes"
@@ -2815,6 +2819,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Queue FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n{count} selected",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Finding FLAC matches... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "No reliable online matches found for the selection",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Added {addedCount} tracks to queue, skipped {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2828,7 +2873,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2863,6 +2908,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Convert from {sourceFormat} to {targetFormat}? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Lossless conversion — no quality loss",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3214,6 +3275,18 @@
}
}
},
"selectionBatchConvertConfirmMessageLossless": "Convert {count} {count, plural, =1{track} other{tracks}} to {format}? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.",
"@selectionBatchConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless batch conversion",
"placeholders": {
"count": {
"type": "int"
},
"format": {
"type": "String"
}
}
},
"selectionBatchConvertProgress": "Converting {current} of {total}...",
"@selectionBatchConvertProgress": {
"description": "Snackbar during batch conversion progress",
@@ -3509,14 +3582,7 @@
"@downloadSelectServiceToEnable": {
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
},
"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"
},
"downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality",
"@downloadSelectTidalQobuz": {
"description": "Info hint when non-Tidal/Qobuz service is selected"
@@ -3602,38 +3668,7 @@
"@downloadMusixmatchAuto": {
"description": "Button to reset Musixmatch language to automatic"
},
"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 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 option"
},
"downloadLossyOpus128": "Opus 128kbps",
"@downloadLossyOpus128": {
"description": "Tidal lossy format option - Opus 128kbps"
},
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
"@downloadLossyOpus128Subtitle": {
"description": "Subtitle for Opus 128kbps option"
},
"downloadNetworkAnySubtitle": "WiFi + Mobile Data",
"@downloadNetworkAnySubtitle": {
"description": "Subtitle for 'Any' network mode option"
@@ -3657,5 +3692,136 @@
"cacheRefresh": "Refresh",
"@cacheRefresh": {
"description": "Tooltip for refresh button on cache management page"
},
"dialogDownloadAllTitle": "Download All",
"@dialogDownloadAllTitle": {
"description": "Dialog title for bulk download confirmation"
},
"dialogDownloadPlaylistsMessage": "Download {trackCount} {trackCount, plural, =1{track} other{tracks}} from {playlistCount} {playlistCount, plural, =1{playlist} other{playlists}}?",
"@dialogDownloadPlaylistsMessage": {
"description": "Dialog message for bulk playlist download confirmation",
"placeholders": {
"trackCount": {
"type": "int"
},
"playlistCount": {
"type": "int"
}
}
},
"bulkDownloadPlaylistsButton": "Download {count} {count, plural, =1{playlist} other{playlists}}",
"@bulkDownloadPlaylistsButton": {
"description": "Button label for bulk downloading selected playlists",
"placeholders": {
"count": {
"type": "int"
}
}
},
"bulkDownloadSelectPlaylists": "Select playlists to download",
"@bulkDownloadSelectPlaylists": {
"description": "Button label when no playlists are selected for download"
},
"snackbarSelectedPlaylistsEmpty": "Selected playlists have no tracks",
"@snackbarSelectedPlaylistsEmpty": {
"description": "Snackbar when selected playlists contain no tracks"
},
"playlistsCount": "{count, plural, =1{1 playlist} other{{count} playlists}}",
"@playlistsCount": {
"description": "Playlist count display",
"placeholders": {
"count": {
"type": "int"
}
}
},
"editMetadataAutoFill": "Auto-fill from online",
"@editMetadataAutoFill": {
"description": "Section title for selective online metadata auto-fill in the edit metadata sheet"
},
"editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata",
"@editMetadataAutoFillDesc": {
"description": "Description for the auto-fill section"
},
"editMetadataAutoFillFetch": "Fetch & Fill",
"@editMetadataAutoFillFetch": {
"description": "Button label to fetch online metadata and fill selected fields"
},
"editMetadataAutoFillSearching": "Searching online...",
"@editMetadataAutoFillSearching": {
"description": "Snackbar shown while searching for online metadata"
},
"editMetadataAutoFillNoResults": "No matching metadata found online",
"@editMetadataAutoFillNoResults": {
"description": "Snackbar when online metadata search returns no results"
},
"editMetadataAutoFillDone": "Filled {count} {count, plural, =1{field} other{fields}} from online metadata",
"@editMetadataAutoFillDone": {
"description": "Snackbar confirming how many fields were auto-filled",
"placeholders": {
"count": {
"type": "int"
}
}
},
"editMetadataAutoFillNoneSelected": "Select at least one field to auto-fill",
"@editMetadataAutoFillNoneSelected": {
"description": "Snackbar when user taps Fetch without selecting any fields"
},
"editMetadataFieldTitle": "Title",
"@editMetadataFieldTitle": {
"description": "Chip label for title field in auto-fill selector"
},
"editMetadataFieldArtist": "Artist",
"@editMetadataFieldArtist": {
"description": "Chip label for artist field in auto-fill selector"
},
"editMetadataFieldAlbum": "Album",
"@editMetadataFieldAlbum": {
"description": "Chip label for album field in auto-fill selector"
},
"editMetadataFieldAlbumArtist": "Album Artist",
"@editMetadataFieldAlbumArtist": {
"description": "Chip label for album artist field in auto-fill selector"
},
"editMetadataFieldDate": "Date",
"@editMetadataFieldDate": {
"description": "Chip label for date field in auto-fill selector"
},
"editMetadataFieldTrackNum": "Track #",
"@editMetadataFieldTrackNum": {
"description": "Chip label for track number field in auto-fill selector"
},
"editMetadataFieldDiscNum": "Disc #",
"@editMetadataFieldDiscNum": {
"description": "Chip label for disc number field in auto-fill selector"
},
"editMetadataFieldGenre": "Genre",
"@editMetadataFieldGenre": {
"description": "Chip label for genre field in auto-fill selector"
},
"editMetadataFieldIsrc": "ISRC",
"@editMetadataFieldIsrc": {
"description": "Chip label for ISRC field in auto-fill selector"
},
"editMetadataFieldLabel": "Label",
"@editMetadataFieldLabel": {
"description": "Chip label for label field in auto-fill selector"
},
"editMetadataFieldCopyright": "Copyright",
"@editMetadataFieldCopyright": {
"description": "Chip label for copyright field in auto-fill selector"
},
"editMetadataFieldCover": "Cover Art",
"@editMetadataFieldCover": {
"description": "Chip label for cover art field in auto-fill selector"
},
"editMetadataSelectAll": "All",
"@editMetadataSelectAll": {
"description": "Button to select all fields for auto-fill"
},
"editMetadataSelectEmpty": "Empty only",
"@editMetadataSelectEmpty": {
"description": "Button to select only fields that are currently empty"
}
}
+59 -2
View File
@@ -2755,6 +2755,47 @@
"@trackReEnrichFfmpegFailed": {
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
},
"queueFlacAction": "Antrekan FLAC",
"@queueFlacAction": {
"description": "Action/button label for queueing FLAC redownloads for local tracks"
},
"queueFlacConfirmMessage": "Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n{count} dipilih",
"@queueFlacConfirmMessage": {
"description": "Confirmation dialog body before queueing FLAC redownloads for local tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueFlacFindingProgress": "Mencari kecocokan FLAC... ({current}/{total})",
"@queueFlacFindingProgress": {
"description": "Snackbar while resolving remote matches for local FLAC redownloads",
"placeholders": {
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"queueFlacNoReliableMatches": "Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini",
"@queueFlacNoReliableMatches": {
"description": "Snackbar when no safe FLAC redownload matches were found"
},
"queueFlacQueuedWithSkipped": "Menambahkan {addedCount} track ke antrean, melewati {skippedCount}",
"@queueFlacQueuedWithSkipped": {
"description": "Snackbar when some selected local tracks were queued for FLAC redownload and some were skipped",
"placeholders": {
"addedCount": {
"type": "int"
},
"skippedCount": {
"type": "int"
}
}
},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
@@ -2768,7 +2809,7 @@
"@trackConvertFormat": {
"description": "Menu item - convert audio format"
},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
"@trackConvertFormatSubtitle": {
"description": "Subtitle for convert format menu item"
},
@@ -2803,6 +2844,22 @@
}
}
},
"trackConvertConfirmMessageLossless": "Konversi dari {sourceFormat} ke {targetFormat}? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.",
"@trackConvertConfirmMessageLossless": {
"description": "Confirmation dialog message for lossless-to-lossless conversion",
"placeholders": {
"sourceFormat": {
"type": "String"
},
"targetFormat": {
"type": "String"
}
}
},
"trackConvertLosslessHint": "Konversi lossless — tanpa kehilangan kualitas",
"@trackConvertLosslessHint": {
"description": "Hint shown when converting between lossless formats"
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {
"description": "Snackbar while converting"
@@ -3114,4 +3171,4 @@
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
}
}
}
+1 -6
View File
@@ -38,10 +38,8 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
final int
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool
@@ -114,7 +112,6 @@ class AppSettings {
this.showExtensionStore = true,
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false,
@@ -178,7 +175,6 @@ class AppSettings {
bool? showExtensionStore,
String? locale,
String? lyricsMode,
String? tidalHighFormat,
int? youtubeOpusBitrate,
int? youtubeMp3Bitrate,
bool? useAllFilesAccess,
@@ -241,7 +237,6 @@ class AppSettings {
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
-2
View File
@@ -44,7 +44,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
@@ -119,7 +118,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
+98 -283
View File
@@ -1840,7 +1840,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return '.opus';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a';
return '.flac'; // HIGH quality no longer available; fallback to FLAC
}
return '.flac';
}
@@ -2383,7 +2383,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
backendResult['album_artist'] as String?,
);
final hasOverrides = backendTrackNum != null ||
final hasOverrides =
backendTrackNum != null ||
backendDiscNum != null ||
backendYear != null ||
backendAlbum != null ||
@@ -3612,6 +3613,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
}
if (!useSaf) {
await _ensureDirExists(outputDir, label: 'Output folder');
}
_log.d('Output dir: $outputDir');
final normalizedTrackNumber =
@@ -3903,7 +3909,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
isContentUriPath &&
effectiveSafMode &&
actualService == 'tidal' &&
quality != 'HIGH' &&
filePath.endsWith('.flac') &&
(mimeType == null || mimeType.contains('flac'));
@@ -3918,73 +3923,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final currentFilePath = filePath;
if (isContentUriPath && effectiveSafMode) {
if (quality == 'HIGH') {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
);
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? convertedPath;
try {
_log.d('M4A file detected (SAF), converting to FLAC...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? flacPath;
try {
final length = await File(tempPath).length();
if (length < 1024) {
_log.w('Temp M4A is too small (<1KB), skipping conversion');
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final format = tidalHighFormat.startsWith('opus')
? 'opus'
: 'mp3';
convertedPath = await FFmpegService.convertM4aToLossy(
tempPath,
format: format,
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,
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath != null) {
_log.d('Converted to FLAC (temp): $flacPath');
_log.d('Embedding metadata and cover to converted FLAC...');
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 (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,
);
}
await _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
final newExt = format == 'opus' ? '.opus' : '.mp3';
final newFileName = '${safBaseName ?? 'track'}$newExt';
final newFileName = '${safBaseName ?? 'track'}.flac';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(newExt),
srcPath: convertedPath,
mimeType: _mimeTypeForExt('.flac'),
srcPath: flacPath,
);
if (newUri != null) {
@@ -3993,58 +3975,60 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
filePath = newUri;
finalSafFileName = newFileName;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
} else {
_log.w(
'Failed to write converted $format to SAF, keeping M4A',
);
actualQuality = 'AAC 320kbps';
_log.w('Failed to write FLAC to SAF, keeping M4A');
}
} else {
_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 {
// Clean up temp files
try {
await File(tempPath).delete();
} catch (_) {}
if (convertedPath != null) {
try {
await File(convertedPath).delete();
} catch (_) {}
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
}
} else {
_log.d('M4A file detected (SAF), converting to FLAC...');
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) {
String? flacPath;
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
} finally {
// Clean up temp files
try {
final length = await File(tempPath).length();
if (length < 1024) {
_log.w('Temp M4A is too small (<1KB), skipping conversion');
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath != null) {
_log.d('Converted to FLAC (temp): $flacPath');
_log.d(
'Embedding metadata and cover to converted FLAC...',
);
await File(tempPath).delete();
} catch (_) {}
if (flacPath != null) {
try {
await File(flacPath).delete();
} catch (_) {}
}
}
}
} 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,
@@ -4055,201 +4039,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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,
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');
}
}
} 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 {
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',
);
}
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
} catch (e) {
_log.w(
'FFmpeg conversion process failed: $e, keeping M4A file',
);
}
} catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
}
}
} else if (metadataEmbeddingEnabled &&
+23 -7
View File
@@ -1,20 +1,22 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 5;
const _currentMigrationVersion = 6;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
@@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
@@ -114,6 +117,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
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);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
@@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return;
final currentDir = state.downloadDirectory.trim();
if (currentDir.isEmpty) return;
final normalizedDir = await validateOrFixIosPath(currentDir);
if (normalizedDir == currentDir) return;
_log.i('Normalized iOS download directory: $currentDir -> $normalizedDir');
state = state.copyWith(downloadDirectory: normalizedDir);
await _saveSettings();
}
String _normalizeSongLinkRegion(String region) {
final normalized = region.trim().toUpperCase();
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
@@ -430,11 +451,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setTidalHighFormat(String format) {
state = state.copyWith(tidalHighFormat: format);
_saveSettings();
}
void setYoutubeOpusBitrate(int bitrate) {
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
state = state.copyWith(youtubeOpusBitrate: normalized);
+5 -2
View File
@@ -95,7 +95,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
.recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
@@ -283,7 +283,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
) {
final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final artistName = widget.artistName ??
(tracks.isNotEmpty
? (tracks.first.albumArtist ?? tracks.first.artistName)
: null);
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverAppBar(
+139 -51
View File
@@ -910,8 +910,44 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
BuildContext context,
List<DownloadHistoryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate =
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -923,7 +959,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -960,51 +995,75 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1057,12 +1116,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: item.filePath.toLowerCase();
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) selected.add(item);
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1074,16 +1140,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1103,8 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
int successCount = 0;
final total = selected.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -1207,13 +1281,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
+1
View File
@@ -3909,6 +3909,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? widget.albumName).toString(),
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId,
@@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState
bool _isSelectionMode = false;
final Set<String> _selectedKeys = {};
UserPlaylistCollection? playlist;
@override
void initState() {
@@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState
final colorScheme = Theme.of(context).colorScheme;
ref.watch(localLibraryProvider.select((s) => s.items));
final localState = ref.read(localLibraryProvider);
final UserPlaylistCollection? playlist;
final List<CollectionTrackEntry> entries;
switch (widget.mode) {
@@ -873,6 +873,7 @@ class _LibraryTracksFolderScreenState
void _downloadAll(List<Track> tracks) {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
final playlistName = widget.mode == LibraryTracksFolderMode.playlist ? playlist?.name ?? context.l10n.collectionPlaylist : null;
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
@@ -885,7 +886,7 @@ class _LibraryTracksFolderScreenState
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: playlistName);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -899,7 +900,7 @@ class _LibraryTracksFolderScreenState
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
.addMultipleToQueue(tracks, settings.defaultService, playlistName: playlistName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
+287 -54
View File
@@ -4,11 +4,15 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
void _showCueVirtualTrackSnackBar() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(cueVirtualTrackRequiresSplitMessage),
),
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
);
}
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@@ -897,6 +900,127 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return false;
}
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
for (final id in _selectedIds) {
final item = tracksById[id];
if (item != null) {
selected.add(item);
}
}
if (selected.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(context.l10n.queueFlacConfirmMessage(selected.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
);
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selected.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.queueFlacFindingProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
}
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final selected = <LocalLibraryItem>[];
@@ -1005,8 +1129,56 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
BuildContext context,
List<LocalLibraryItem> allTracks,
) {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final tracksById = {for (final t in allTracks) t.id: t};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
String? ext;
if (item.format != null && item.format!.isNotEmpty) {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
ext = 'FLAC';
} else if (fmt == 'm4a') {
ext = 'M4A';
} else if (fmt == 'mp3') {
ext = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
ext = 'Opus';
}
}
if (ext == null) {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
ext = 'FLAC';
} else if (lower.endsWith('.m4a')) {
ext = 'M4A';
} else if (lower.endsWith('.mp3')) {
ext = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ext = 'Opus';
}
}
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate =
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
showModalBottomSheet(
context: context,
@@ -1018,7 +1190,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -1055,51 +1226,75 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -1152,6 +1347,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final fmt = item.format!.toLowerCase();
if (fmt == 'flac') {
currentFormat = 'FLAC';
} else if (fmt == 'm4a') {
currentFormat = 'M4A';
} else if (fmt == 'mp3') {
currentFormat = 'MP3';
} else if (fmt == 'opus' || fmt == 'ogg') {
@@ -1163,15 +1360,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final lower = item.filePath.toLowerCase();
if (lower.endsWith('.flac')) {
currentFormat = 'FLAC';
} else if (lower.endsWith('.m4a')) {
currentFormat = 'M4A';
} else if (lower.endsWith('.mp3')) {
currentFormat = 'MP3';
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
currentFormat = 'Opus';
}
}
if (currentFormat != null && currentFormat != targetFormat) {
selected.add(item);
}
if (currentFormat == null || currentFormat == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selected.add(item);
}
if (selected.isEmpty) {
@@ -1183,16 +1385,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selected.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selected.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -1357,13 +1565,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
@@ -1525,6 +1747,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Row(
children: [
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.download_for_offline_outlined,
label: '${context.l10n.queueFlacAction} ($selectedCount)',
onPressed: selectedCount > 0
? () => _queueSelectedAsFlac(tracks)
: null,
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
Expanded(
child: _LocalAlbumSelectionActionButton(
icon: Icons.auto_fix_high_outlined,
+1 -1
View File
@@ -533,7 +533,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks.isEmpty
? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
: () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName),
);
}
+402 -53
View File
@@ -17,11 +17,13 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -31,6 +33,7 @@ import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
enum LibraryItemSource { downloaded, local }
@@ -1314,6 +1317,94 @@ class _QueueTabState extends ConsumerState<QueueTab> {
});
}
Future<void> _downloadAllSelectedPlaylists(BuildContext context) async {
final collectionsState = ref.read(libraryCollectionsProvider);
final selectedPlaylists = collectionsState.playlists
.where((p) => _selectedPlaylistIds.contains(p.id))
.toList();
final totalTracks = selectedPlaylists.fold<int>(
0,
(sum, p) => sum + p.tracks.length,
);
if (totalTracks == 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarSelectedPlaylistsEmpty)),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.dialogDownloadAllTitle),
content: Text(
ctx.l10n.dialogDownloadPlaylistsMessage(
totalTracks,
selectedPlaylists.length,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.dialogDownload),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final settings = ref.read(settingsProvider);
final queueNotifier = ref.read(downloadQueueProvider.notifier);
void enqueueAll({String? qualityOverride, String? service}) {
final svc = service ?? settings.defaultService;
for (final playlist in selectedPlaylists) {
final tracks = playlist.tracks.map((e) => e.track).toList();
queueNotifier.addMultipleToQueue(
tracks,
svc,
qualityOverride: qualityOverride,
playlistName: playlist.name,
);
}
}
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: context.l10n.tracksCount(totalTracks),
artistName: context.l10n.playlistsCount(selectedPlaylists.length),
onSelect: (quality, service) {
enqueueAll(qualityOverride: quality, service: service);
if (!mounted) return;
_exitPlaylistSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(totalTracks),
),
),
);
},
);
} else {
enqueueAll();
_exitPlaylistSelectionMode();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(totalTracks)),
),
);
}
}
Future<void> _deleteSelectedPlaylists(BuildContext context) async {
final count = _selectedPlaylistIds.length;
final confirmed = await showDialog<bool>(
@@ -1452,6 +1543,37 @@ class _QueueTabState extends ConsumerState<QueueTab> {
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0
? () => _downloadAllSelectedPlaylists(context)
: null,
icon: const Icon(Icons.download_rounded),
label: Text(
selectedCount > 0
? context.l10n.bulkDownloadPlaylistsButton(
selectedCount,
)
: context.l10n.bulkDownloadSelectPlaylists,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0
? colorScheme.primary
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
@@ -4362,6 +4484,127 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return false;
}
Future<void> _queueSelectedLocalAsFlac(
List<UnifiedLibraryItem> allItems,
) async {
final selectedItems = _selectedItemsFromAll(allItems);
final selectedLocalItems = selectedItems
.map((item) => item.localItem)
.whereType<LocalLibraryItem>()
.toList(growable: false);
if (selectedLocalItems.isEmpty) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.queueFlacAction),
content: Text(
context.l10n.queueFlacConfirmMessage(selectedLocalItems.length),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.queueFlacAction),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final includeExtensions =
settings.useExtensionProviders &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.hasMetadataProvider,
);
final targetService = LocalTrackRedownloadService.preferredFlacService(
settings,
);
final targetQuality =
LocalTrackRedownloadService.preferredFlacQualityForService(
targetService,
);
final matchedTracks = <Track>[];
var skippedCount = 0;
final total = selectedLocalItems.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.queueFlacFindingProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selectedLocalItems[i],
includeExtensions: includeExtensions,
);
if (resolution.canQueue && resolution.match != null) {
matchedTracks.add(resolution.match!);
} else {
skippedCount++;
}
} catch (_) {
skippedCount++;
}
}
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)),
);
return;
}
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
matchedTracks,
targetService,
qualityOverride: targetQuality,
);
final summary = skippedCount == 0
? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length)
: context.l10n.queueFlacQueuedWithSkipped(
matchedTracks.length,
skippedCount,
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(summary)));
setState(() {
_selectedIds.clear();
_isSelectionMode = false;
});
}
Future<void> _reEnrichSelectedLocalFromQueue(
List<UnifiedLibraryItem> allItems,
) async {
@@ -4512,8 +4755,50 @@ class _QueueTabState extends ConsumerState<QueueTab> {
BuildContext context,
List<UnifiedLibraryItem> allItems,
) async {
String selectedFormat = 'MP3';
String selectedBitrate = '320k';
final itemsById = {for (final item in allItems) item.id: item};
final sourceFormats = <String>{};
for (final id in _selectedIds) {
final item = itemsById[id];
if (item == null) continue;
String nameToCheck;
if (item.historyItem?.safFileName != null &&
item.historyItem!.safFileName!.isNotEmpty) {
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
} else if (item.localItem?.format != null &&
item.localItem!.format!.isNotEmpty) {
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
} else {
nameToCheck = item.filePath.toLowerCase();
}
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null) sourceFormats.add(ext);
}
final formats = ['ALAC', 'FLAC', 'MP3', 'Opus'].where((target) {
return sourceFormats.any((src) {
if (src == target) return false;
final isLosslessTarget = target == 'ALAC' || target == 'FLAC';
final isLosslessSource = src == 'FLAC' || src == 'M4A';
if (isLosslessTarget && !isLosslessSource) return false;
return true;
});
}).toList();
if (formats.isEmpty) return;
String selectedFormat = formats.first;
bool isLosslessTarget =
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
String selectedBitrate =
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
var didStartConversion = false;
_hideSelectionOverlay();
@@ -4529,7 +4814,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return StatefulBuilder(
builder: (context, setSheetState) {
final colorScheme = Theme.of(context).colorScheme;
final formats = ['MP3', 'Opus'];
final bitrates = ['128k', '192k', '256k', '320k'];
return SafeArea(
@@ -4566,51 +4850,75 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
const SizedBox(height: 8),
Row(
children: formats.map((format) {
final isSelected = format == selectedFormat;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() {
selectedFormat = format;
selectedBitrate = format == 'Opus'
? '128k'
: '320k';
});
}
},
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
children: formats.map((format) {
final isSelected = format == selectedFormat;
return ChoiceChip(
label: Text(br),
label: Text(format),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
setSheetState(() {
selectedFormat = format;
isLosslessTarget =
format == 'ALAC' || format == 'FLAC';
if (!isLosslessTarget) {
selectedBitrate =
format == 'Opus' ? '128k' : '320k';
}
});
}
},
);
}).toList(),
),
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
context.l10n.trackConvertBitrate,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: bitrates.map((br) {
final isSelected = br == selectedBitrate;
return ChoiceChip(
label: Text(br),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setSheetState(() => selectedBitrate = br);
}
},
);
}).toList(),
),
],
if (isLosslessTarget) ...[
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.verified,
size: 16,
color: colorScheme.primary,
),
const SizedBox(width: 6),
Text(
context.l10n.trackConvertLosslessHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@@ -4686,14 +4994,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
final ext = nameToCheck.endsWith('.flac')
? 'FLAC'
: nameToCheck.endsWith('.m4a')
? 'M4A'
: nameToCheck.endsWith('.mp3')
? 'MP3'
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
? 'Opus'
: null;
if (ext != null && ext != targetFormat) {
selectedItems.add(item);
}
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
selectedItems.add(item);
}
if (selectedItems.isEmpty) {
@@ -4706,16 +5019,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
// Confirm
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
content: Text(
context.l10n.selectionBatchConvertConfirmMessage(
selectedItems.length,
targetFormat,
bitrate,
),
isLossless
? context.l10n.selectionBatchConvertConfirmMessageLossless(
selectedItems.length,
targetFormat,
)
: context.l10n.selectionBatchConvertConfirmMessage(
selectedItems.length,
targetFormat,
bitrate,
),
),
actions: [
TextButton(
@@ -4735,8 +5054,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
int successCount = 0;
final total = selectedItems.length;
final historyDb = HistoryDatabase.instance;
final newQuality =
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
targetFormat.toUpperCase() == 'FLAC')
? '${targetFormat.toUpperCase()} Lossless'
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
final settings = ref.read(settingsProvider);
final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external';
@@ -4850,13 +5171,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final baseName = dotIdx > 0
? oldFileName.substring(0, dotIdx)
: oldFileName;
final newExt = targetFormat.toLowerCase() == 'opus'
? '.opus'
: '.mp3';
String newExt;
String mimeType;
switch (targetFormat.toLowerCase()) {
case 'opus':
newExt = '.opus';
mimeType = 'audio/opus';
break;
case 'alac':
newExt = '.m4a';
mimeType = 'audio/mp4';
break;
case 'flac':
newExt = '.flac';
mimeType = 'audio/flac';
break;
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
}
final newFileName = '$baseName$newExt';
final mimeType = targetFormat.toLowerCase() == 'opus'
? 'audio/opus'
: 'audio/mpeg';
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
@@ -5129,6 +5464,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
// Action buttons row: Share/Re-enrich, Convert, Delete
Row(
children: [
if (localOnlySelection) ...[
Expanded(
child: _SelectionActionButton(
icon: Icons.download_for_offline_outlined,
label:
'${context.l10n.queueFlacAction} ($selectedCount)',
onPressed: selectedCount > 0
? () => _queueSelectedLocalAsFlac(unifiedItems)
: null,
colorScheme: colorScheme,
),
),
const SizedBox(width: 8),
],
Expanded(
child: _SelectionActionButton(
icon: localOnlySelection
@@ -300,7 +300,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
return PopScope(
canPop: true,
@@ -408,35 +407,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: isTidalService,
showDivider: false,
),
// Lossy 320kbps option (Tidal only) - downloads M4A, converts to MP3/Opus
if (isTidalService)
_QualityOption(
title: context.l10n.downloadLossy320,
subtitle: _getTidalHighFormatLabel(
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(
settings.tidalHighFormat,
),
onTap: () => _showTidalHighFormatPicker(
context,
ref,
settings.tidalHighFormat,
),
showDivider: false,
),
],
if (!isBuiltInService) ...[
Padding(
@@ -464,12 +436,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
],
SettingsItem(
title: context.l10n.youtubeOpusBitrateTitle,
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)',
onTap: () => _showYoutubeBitratePicker(
context: context,
title: context.l10n.youtubeOpusBitrateTitle,
currentValue: settings.youtubeOpusBitrate,
options: const [128, 256],
options: const [128, 256, 320],
onSave: (value) => ref
.read(settingsProvider.notifier)
.setYoutubeOpusBitrate(value),
@@ -1691,104 +1663,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
);
}
String _getTidalHighFormatLabel(String format) {
switch (format) {
case 'mp3_320':
return 'MP3 320kbps';
case 'opus_256':
return 'Opus 256kbps';
case 'opus_128':
return 'Opus 128kbps';
default:
return 'MP3 320kbps';
}
}
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 _showNetworkModePicker(
BuildContext context,
WidgetRef ref,
File diff suppressed because it is too large Load Diff
+273 -2
View File
@@ -1209,7 +1209,8 @@ class FFmpegService {
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
/// Supports: FLAC/M4A/MP3/Opus -> MP3/Opus/ALAC/FLAC.
/// ALAC and FLAC targets are lossless (bitrate parameter is ignored).
/// Returns the new file path on success, null on failure.
static Future<String?> convertAudioFormat({
required String inputPath,
@@ -1220,11 +1221,30 @@ class FFmpegService {
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
if (format != 'mp3' && format != 'opus') {
if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) {
_log.e('Unsupported target format: $targetFormat');
return null;
}
// Lossless targets: dedicated single-pass methods
if (format == 'alac') {
return _convertToAlac(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: deleteOriginal,
);
}
if (format == 'flac') {
return _convertToFlac(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: deleteOriginal,
);
}
// Lossy targets: MP3 / Opus
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
@@ -1296,6 +1316,257 @@ class FFmpegService {
return outputPath;
}
/// Convert any audio format to ALAC (Apple Lossless) in an M4A container.
/// Metadata and cover art are embedded in a single FFmpeg pass.
static Future<String?> _convertToAlac({
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.m4a');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
// Cover art as second input for M4A attached picture
final hasCover = coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
}
cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 ');
// Embed M4A metadata tags
final m4aTags = _convertToM4aTags(metadata);
for (final entry in m4aTags.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
cmdBuffer.write('"$outputPath" -y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC',
);
final result = await _execute(cmdBuffer.toString());
if (!result.success) {
_log.e('ALAC conversion failed: ${result.output}');
return null;
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Convert any audio format to FLAC with metadata and cover art preservation.
static Future<String?> _convertToFlac({
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
final hasCover = coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a flac -compression_level 8 ');
cmdBuffer.write('-map_metadata 0 ');
final vorbisComments = _normalizeToVorbisComments(metadata);
for (final entry in vorbisComments.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
cmdBuffer.write('"$outputPath" -y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC',
);
final result = await _execute(cmdBuffer.toString());
if (!result.success) {
_log.e('FLAC conversion failed: ${result.output}');
return null;
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
/// Normalize metadata keys to standard Vorbis comment names, filtering out
/// technical fields (bit_depth, sample_rate, duration, etc.).
static Map<String, String> _normalizeToVorbisComments(
Map<String, String> metadata,
) {
final vorbis = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (key) {
case 'TITLE':
vorbis['TITLE'] = value;
break;
case 'ARTIST':
vorbis['ARTIST'] = value;
break;
case 'ALBUM':
vorbis['ALBUM'] = value;
break;
case 'ALBUMARTIST':
vorbis['ALBUMARTIST'] = value;
break;
case 'TRACKNUMBER':
case 'TRACKNBR':
case 'TRACK':
case 'TRCK':
if (value != '0') vorbis['TRACKNUMBER'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
if (value != '0') vorbis['DISCNUMBER'] = value;
break;
case 'DATE':
case 'YEAR':
vorbis['DATE'] = value;
break;
case 'GENRE':
vorbis['GENRE'] = value;
break;
case 'ISRC':
vorbis['ISRC'] = value;
break;
case 'LABEL':
case 'ORGANIZATION':
vorbis['ORGANIZATION'] = value;
break;
case 'COPYRIGHT':
vorbis['COPYRIGHT'] = value;
break;
case 'COMPOSER':
vorbis['COMPOSER'] = value;
break;
case 'COMMENT':
vorbis['COMMENT'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
vorbis['LYRICS'] = value;
vorbis['UNSYNCEDLYRICS'] = value;
break;
}
}
return vorbis;
}
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
static Map<String, String> _convertToM4aTags(
Map<String, String> metadata,
) {
final m4aMap = <String, String>{};
for (final entry in metadata.entries) {
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
if (value.trim().isEmpty) continue;
switch (key) {
case 'TITLE':
m4aMap['title'] = value;
break;
case 'ARTIST':
m4aMap['artist'] = value;
break;
case 'ALBUM':
m4aMap['album'] = value;
break;
case 'ALBUMARTIST':
m4aMap['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
m4aMap['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
m4aMap['disc'] = value;
break;
case 'DATE':
case 'YEAR':
m4aMap['date'] = value;
break;
case 'GENRE':
m4aMap['genre'] = value;
break;
case 'COMPOSER':
m4aMap['composer'] = value;
break;
case 'COMMENT':
m4aMap['comment'] = value;
break;
case 'COPYRIGHT':
m4aMap['copyright'] = value;
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
m4aMap['lyrics'] = value;
break;
}
}
return m4aMap;
}
static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata,
) {
@@ -0,0 +1,338 @@
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class LocalTrackRedownloadResolution {
final LocalLibraryItem localItem;
final Track? match;
final int score;
final String reason;
const LocalTrackRedownloadResolution({
required this.localItem,
required this.match,
required this.score,
required this.reason,
});
bool get canQueue => match != null;
}
class LocalTrackRedownloadService {
static const int _minimumConfidenceScore = 85;
static const int _ambiguousScoreGap = 8;
static Future<LocalTrackRedownloadResolution> resolveBestMatch(
LocalLibraryItem item, {
required bool includeExtensions,
}) async {
final query = _buildSearchQuery(item);
final rawResults = await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 10,
includeExtensions: includeExtensions,
);
if (rawResults.isEmpty) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: 0,
reason: 'No candidates found',
);
}
final scored =
rawResults
.map(
(raw) => (
track: _parseSearchTrack(raw),
score: _scoreMatch(item, raw),
),
)
.where((entry) => entry.track.name.trim().isNotEmpty)
.toList(growable: false)
..sort((a, b) => b.score.compareTo(a.score));
if (scored.isEmpty) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: 0,
reason: 'No usable candidates found',
);
}
final best = scored.first;
final runnerUp = scored.length > 1 ? scored[1] : null;
final exactIsrc =
_normalizedIsrc(item.isrc) != null &&
_normalizedIsrc(item.isrc) == _normalizedIsrc(best.track.isrc);
final isAmbiguous =
!exactIsrc &&
runnerUp != null &&
best.score < (_minimumConfidenceScore + 10) &&
(best.score - runnerUp.score) <= _ambiguousScoreGap;
if (!exactIsrc && (best.score < _minimumConfidenceScore || isAmbiguous)) {
return LocalTrackRedownloadResolution(
localItem: item,
match: null,
score: best.score,
reason: isAmbiguous ? 'Ambiguous match' : 'Low-confidence match',
);
}
return LocalTrackRedownloadResolution(
localItem: item,
match: best.track,
score: best.score,
reason: exactIsrc ? 'Exact ISRC match' : 'High-confidence metadata match',
);
}
static String preferredFlacService(AppSettings settings) {
switch (settings.defaultService.toLowerCase()) {
case 'tidal':
case 'qobuz':
case 'deezer':
return settings.defaultService.toLowerCase();
default:
return 'tidal';
}
}
static String preferredFlacQualityForService(String service) {
return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS';
}
static String _buildSearchQuery(LocalLibraryItem item) {
final artist = _primaryArtist(item.artistName);
final album = item.albumName.trim();
if (album.isNotEmpty && album.toLowerCase() != 'unknown album') {
return '${item.trackName} $artist $album'.trim();
}
return '${item.trackName} $artist'.trim();
}
static Track _parseSearchTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?,
source: data['source']?.toString() ?? data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
}
static int _extractDurationMs(Map<String, dynamic> data) {
final durationMsRaw = data['duration_ms'];
if (durationMsRaw is num && durationMsRaw > 0) {
return durationMsRaw.toInt();
}
if (durationMsRaw is String) {
final parsed = num.tryParse(durationMsRaw.trim());
if (parsed != null && parsed > 0) {
return parsed.toInt();
}
}
final durationSecRaw = data['duration'];
if (durationSecRaw is num && durationSecRaw > 0) {
return (durationSecRaw * 1000).toInt();
}
if (durationSecRaw is String) {
final parsed = num.tryParse(durationSecRaw.trim());
if (parsed != null && parsed > 0) {
return (parsed * 1000).toInt();
}
}
return 0;
}
static int _scoreMatch(LocalLibraryItem item, Map<String, dynamic> raw) {
final track = _parseSearchTrack(raw);
var score = 0;
final localIsrc = _normalizedIsrc(item.isrc);
final candidateIsrc = _normalizedIsrc(track.isrc);
if (localIsrc != null && candidateIsrc != null) {
score += localIsrc == candidateIsrc ? 140 : -120;
}
final localTitle = _normalizedTitle(item.trackName);
final candidateTitle = _normalizedTitle(track.name);
if (localTitle == candidateTitle) {
score += 45;
} else if (_tokenOverlap(localTitle, candidateTitle) >= 0.75) {
score += 24;
} else {
score -= 25;
}
final localArtist = _normalizedArtistGroup(item.artistName);
final candidateArtist = _normalizedArtistGroup(track.artistName);
final artistOverlap = _tokenOverlap(localArtist, candidateArtist);
if (localArtist == candidateArtist) {
score += 30;
} else if (artistOverlap >= 0.6) {
score += 16;
} else {
score -= 20;
}
final localAlbum = _normalizedText(item.albumName);
final candidateAlbum = _normalizedText(track.albumName);
if (localAlbum.isNotEmpty && candidateAlbum.isNotEmpty) {
if (localAlbum == candidateAlbum) {
score += 12;
} else if (_tokenOverlap(localAlbum, candidateAlbum) >= 0.7) {
score += 6;
}
}
final localDuration = item.duration ?? 0;
final candidateDuration = track.duration;
if (localDuration > 0 && candidateDuration > 0) {
final diff = (localDuration - candidateDuration).abs();
if (diff <= 2) {
score += 20;
} else if (diff <= 5) {
score += 12;
} else if (diff <= 10) {
score += 5;
} else if (diff > 20) {
score -= 30;
}
}
if (item.trackNumber != null &&
track.trackNumber != null &&
item.trackNumber == track.trackNumber) {
score += 6;
}
if (item.discNumber != null &&
track.discNumber != null &&
item.discNumber == track.discNumber) {
score += 4;
}
final localYear = _extractYear(item.releaseDate);
final candidateYear = _extractYear(track.releaseDate);
if (localYear != null &&
candidateYear != null &&
localYear == candidateYear) {
score += 4;
}
score += _versionPenalty(item.trackName, track.name);
return score;
}
static String? _normalizedIsrc(String? value) {
final normalized = value?.trim().toUpperCase();
if (normalized == null || normalized.isEmpty) {
return null;
}
return normalized;
}
static String _normalizedTitle(String value) {
final cleaned = _normalizedText(value)
.replaceAll(RegExp(r'\b(feat|ft|featuring)\b.*$'), ' ')
.replaceAll(RegExp(r'\b(remaster(?:ed)?|deluxe|bonus)\b'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned;
}
static String _normalizedArtistGroup(String value) {
return _normalizedText(
value
.replaceAll(RegExp(r'\b(feat|ft|featuring|with|x)\b'), ',')
.replaceAll('&', ','),
);
}
static String _primaryArtist(String value) {
final parts = _normalizedArtistGroup(
value,
).split(',').map((part) => part.trim()).where((part) => part.isNotEmpty);
return parts.isEmpty ? value.trim() : parts.first;
}
static String _normalizedText(String value) {
return value
.toLowerCase()
.replaceAll(RegExp(r'[\(\)\[\]\{\}]'), ' ')
.replaceAll(RegExp(r'[^a-z0-9, ]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
}
static double _tokenOverlap(String left, String right) {
final leftTokens = left
.split(RegExp(r'[\s,]+'))
.where((token) => token.isNotEmpty)
.toSet();
final rightTokens = right
.split(RegExp(r'[\s,]+'))
.where((token) => token.isNotEmpty)
.toSet();
if (leftTokens.isEmpty || rightTokens.isEmpty) {
return 0;
}
final intersection = leftTokens.intersection(rightTokens).length;
final denominator = leftTokens.length > rightTokens.length
? leftTokens.length
: rightTokens.length;
return intersection / denominator;
}
static int _versionPenalty(String localTitle, String candidateTitle) {
const riskyMarkers = [
'live',
'karaoke',
'instrumental',
'acoustic',
'radio edit',
'sped up',
'slowed',
];
final local = _normalizedText(localTitle);
final candidate = _normalizedText(candidateTitle);
var penalty = 0;
for (final marker in riskyMarkers) {
final localHas = local.contains(marker);
final candidateHas = candidate.contains(marker);
if (!localHas && candidateHas) {
penalty -= 18;
}
}
return penalty;
}
static int? _extractYear(String? date) {
if (date == null || date.length < 4) {
return null;
}
return int.tryParse(date.substring(0, 4));
}
}
+41 -9
View File
@@ -20,6 +20,22 @@ final _iosLegacyRelativeDocumentsPattern = RegExp(
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
final _iosNestedLegacyDocumentsPattern = RegExp(
r'/Documents/Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
caseSensitive: false,
);
String _normalizeRecoveredIosSuffix(String suffix) {
final trimmed = suffix.trim();
if (trimmed.isEmpty) return '';
return trimmed.startsWith('/') ? trimmed.substring(1) : trimmed;
}
String _joinRecoveredIosPath(String documentsPath, String suffix) {
final normalizedSuffix = _normalizeRecoveredIosSuffix(suffix);
if (normalizedSuffix.isEmpty) return documentsPath;
return '$documentsPath/$normalizedSuffix';
}
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
@@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) {
return false;
}
// Reject stale paths where an old sandbox container path has been embedded
// inside the current Documents directory.
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
@@ -70,11 +92,19 @@ Future<String> validateOrFixIosPath(
if (!Platform.isIOS) return path;
final trimmed = path.trim();
final docDir = await getApplicationDocumentsDirectory();
final nestedLegacyMatch = _iosNestedLegacyDocumentsPattern.firstMatch(
trimmed,
);
if (nestedLegacyMatch != null) {
return _joinRecoveredIosPath(docDir.path, nestedLegacyMatch.group(1) ?? '');
}
if (isValidIosWritablePath(trimmed)) {
return trimmed;
}
final docDir = await getApplicationDocumentsDirectory();
final candidates = <String>[];
if (trimmed.isNotEmpty) {
@@ -92,14 +122,8 @@ Future<String> validateOrFixIosPath(
trimmed,
);
if (legacyRelativeMatch != null) {
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
final normalizedSuffix = suffix.startsWith('/')
? suffix.substring(1)
: suffix;
candidates.add(
normalizedSuffix.isEmpty
? docDir.path
: '${docDir.path}/$normalizedSuffix',
_joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''),
);
}
@@ -109,7 +133,7 @@ Future<String> validateOrFixIosPath(
final index = trimmed.indexOf(documentsMarker);
if (index >= 0) {
final suffix = trimmed.substring(index + documentsMarker.length).trim();
candidates.add(suffix.isEmpty ? docDir.path : '${docDir.path}/$suffix');
candidates.add(_joinRecoveredIosPath(docDir.path, suffix));
}
}
@@ -181,6 +205,14 @@ IosPathValidationResult validateIosPath(String path) {
);
}
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason:
'Invalid iOS app folder path. Please choose App Documents or another local folder.',
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
+5 -5
View File
@@ -23,7 +23,7 @@ class BuiltInService {
}
/// Default quality options for built-in services
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
/// Default quality options for each built-in service
const _builtInServices = [
BuiltInService(
id: 'tidal',
@@ -83,9 +83,9 @@ const _builtInServices = [
label: 'YouTube',
qualityOptions: [
QualityOption(
id: 'opus_256',
label: 'Opus 256kbps',
description: 'Best quality lossy (~8MB per track)',
id: 'opus_320',
label: 'Opus 320kbps',
description: 'Best quality lossy (~10MB per track)',
),
QualityOption(
id: 'mp3_320',
@@ -146,7 +146,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
}
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256];
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
late String _selectedService;
+7 -5
View File
@@ -20,6 +20,7 @@ Future<void> showAddTracksToPlaylistSheet(
BuildContext context,
WidgetRef ref,
List<Track> tracks,
{String? playlistNamePrefill}
) async {
if (tracks.isEmpty) return;
@@ -31,15 +32,16 @@ Future<void> showAddTracksToPlaylistSheet(
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return _PlaylistPickerSheetContent(tracks: tracks);
return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill);
},
);
}
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
final List<Track> tracks;
final String? playlistNamePrefill;
const _PlaylistPickerSheetContent({required this.tracks});
const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill});
@override
ConsumerState<_PlaylistPickerSheetContent> createState() =>
@@ -130,7 +132,7 @@ class _PlaylistPickerSheetContentState
leading: const Icon(Icons.add_circle_outline),
title: Text(context.l10n.collectionCreatePlaylist),
onTap: () async {
final name = await _promptPlaylistName(context);
final name = await _promptPlaylistName(context, widget.playlistNamePrefill);
if (name == null || name.trim().isEmpty || !context.mounted) {
return;
}
@@ -221,8 +223,8 @@ class _PlaylistPickerSheetContentState
}
}
Future<String?> _promptPlaylistName(BuildContext context) async {
final controller = TextEditingController();
Future<String?> _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async {
final controller = TextEditingController(text: playlistNamePrefill);
final formKey = GlobalKey<FormState>();
final result = await showDialog<String>(
+12 -20
View File
@@ -133,10 +133,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -557,14 +557,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
@@ -633,18 +625,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
@@ -1166,26 +1158,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.12"
version: "0.6.16"
timezone:
dependency: transitive
description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 3.8.0+106
version: 3.8.5+111
environment:
sdk: ^3.10.0