mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| f67f52eba9 | |||
| 18607597e9 | |||
| 78cd396847 | |||
| 8540da484f | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| c347b6999e | |||
| adc74741ce |
@@ -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]
|
||||
|
||||
@@ -77,3 +77,6 @@ flutter_*.log
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
+17
-3
@@ -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
|
||||
```
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||
[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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", "":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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\-]+',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user