mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
notify-telegram:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [get-version, create-release]
|
needs: [get-version, create-release]
|
||||||
|
|||||||
@@ -77,3 +77,6 @@ flutter_*.log
|
|||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# FVM Version Cache
|
||||||
|
.fvm/
|
||||||
|
|||||||
+2
-2
@@ -334,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||||
- New backend client for `spotify.afkarxyz.fun/api`
|
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||||
- Includes heuristic detection of lyrics stored in Comment fields
|
- Includes heuristic detection of lyrics stored in Comment fields
|
||||||
@@ -349,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
|
|||||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||||
|
|||||||
+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
|
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
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
```bash
|
```bash
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
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
|
```bash
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](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://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
[](https://t.me/spotiflac)
|
[](https://t.me/spotiflac)
|
||||||
@@ -40,10 +40,11 @@ Extensions allow the community to add new music sources and features without wai
|
|||||||
|
|
||||||
### Installing Extensions
|
### Installing Extensions
|
||||||
1. Go to **Store** tab in the app
|
1. Go to **Store** tab in the app
|
||||||
2. Browse and install extensions with one tap
|
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
|
||||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
3. Browse and install extensions with one tap
|
||||||
4. Configure extension settings if needed
|
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
5. Configure extension settings if needed
|
||||||
|
6. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||||
|
|
||||||
### Developing Extensions
|
### Developing Extensions
|
||||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
||||||
@@ -55,6 +56,9 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
|||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Why does the Store tab ask me to enter a URL?**
|
||||||
|
A: Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system — extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
**Q: Why is my download failing with "Song not found"?**
|
||||||
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
|
||||||
|
|
||||||
@@ -73,6 +77,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?**
|
**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.
|
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?
|
### Want to support SpotiFLAC-Mobile?
|
||||||
|
|
||||||
@@ -80,6 +89,18 @@ _If this software is useful and brings you value, consider supporting the projec
|
|||||||
|
|
||||||
[](https://ko-fi.com/zarzet)
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
|
||||||
|
|
||||||
|
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
We also appreciate everyone who has helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word about SpotiFLAC Mobile.
|
||||||
|
|
||||||
|
Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||||
|
|
||||||
## API Credits
|
## API Credits
|
||||||
|
|
||||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||||
@@ -87,4 +108,4 @@ _If this software is useful and brings you value, consider supporting the projec
|
|||||||
|
|
||||||
> [!TIP]
|
> [!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.6",
|
||||||
|
"versionDate": "2026-03-16",
|
||||||
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.8.6/SpotiFLAC-v3.8.6-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": 33676960
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -256,6 +256,7 @@ type deezerAlbumFull struct {
|
|||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
RecordType string `json:"record_type"`
|
RecordType string `json:"record_type"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
Genres struct {
|
Genres struct {
|
||||||
Data []deezerGenre `json:"data"`
|
Data []deezerGenre `json:"data"`
|
||||||
} `json:"genres"`
|
} `json:"genres"`
|
||||||
@@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AlbumExtendedMetadata struct {
|
type AlbumExtendedMetadata struct {
|
||||||
Genre string
|
Genre string
|
||||||
Label string
|
Label string
|
||||||
|
Copyright string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
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{
|
result := &AlbumExtendedMetadata{
|
||||||
Genre: strings.Join(genres, ", "),
|
Genre: strings.Join(genres, ", "),
|
||||||
Label: album.Label,
|
Label: album.Label,
|
||||||
|
Copyright: album.Copyright,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
@@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
|||||||
c.maybeCleanupCachesLocked(now)
|
c.maybeCleanupCachesLocked(now)
|
||||||
c.cacheMu.Unlock()
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,29 +203,48 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if deezerID != "" {
|
if deezerID != "" {
|
||||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||||
|
if err := verifyDeezerTrack(req, deezerID); err != nil {
|
||||||
|
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||||
|
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||||
|
}
|
||||||
|
return trackURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try resolving Deezer ID from Spotify ID via SongLink
|
// Try SongLink
|
||||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||||
return availability.DeezerURL, nil
|
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||||
|
if resolvedID != "" {
|
||||||
|
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
// Fall through to ISRC search instead of using wrong track.
|
||||||
|
} else {
|
||||||
|
return availability.DeezerURL, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return availability.DeezerURL, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try resolving from ISRC
|
// Try ISRC
|
||||||
isrc := strings.TrimSpace(req.ISRC)
|
isrc := strings.TrimSpace(req.ISRC)
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||||
if err == nil && track != nil {
|
if err == nil && track != nil {
|
||||||
deezerID = songLinkExtractDeezerTrackID(track)
|
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||||
if deezerID != "" {
|
if resolvedID != "" {
|
||||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +252,26 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
|||||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil // Can't verify — don't block the download.
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: trackResp.Track.Name,
|
||||||
|
ArtistName: trackResp.Track.Artists,
|
||||||
|
Duration: trackResp.Track.DurationMS / 1000,
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||||
|
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||||
|
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||||
|
}
|
||||||
|
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type deezerMusicDLRequest struct {
|
type deezerMusicDLRequest struct {
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
|||||||
+106
-30
@@ -135,6 +135,36 @@ type DownloadResult struct {
|
|||||||
DecryptionKey string
|
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(
|
func buildDownloadSuccessResponse(
|
||||||
req DownloadRequest,
|
req DownloadRequest,
|
||||||
result DownloadResult,
|
result DownloadResult,
|
||||||
@@ -153,25 +183,16 @@ func buildDownloadSuccessResponse(
|
|||||||
artist = req.ArtistName
|
artist = req.ArtistName
|
||||||
}
|
}
|
||||||
|
|
||||||
album := result.Album
|
// Preserve requested release metadata when available so mixed-provider
|
||||||
if album == "" {
|
// fallback downloads from the same source album do not get split into
|
||||||
album = req.AlbumName
|
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||||
}
|
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||||
|
req,
|
||||||
releaseDate := result.ReleaseDate
|
result.Album,
|
||||||
if releaseDate == "" {
|
result.ReleaseDate,
|
||||||
releaseDate = req.ReleaseDate
|
result.TrackNumber,
|
||||||
}
|
result.DiscNumber,
|
||||||
|
)
|
||||||
trackNumber := result.TrackNumber
|
|
||||||
if trackNumber == 0 {
|
|
||||||
trackNumber = req.TrackNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
discNumber := result.DiscNumber
|
|
||||||
if discNumber == 0 {
|
|
||||||
discNumber = req.DiscNumber
|
|
||||||
}
|
|
||||||
|
|
||||||
isrc := result.ISRC
|
isrc := result.ISRC
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
@@ -262,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ISRC == "" || (req.Genre != "" && req.Label != "") {
|
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
|||||||
if req.Label == "" && extMeta.Label != "" {
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
}
|
}
|
||||||
if req.Genre != "" || req.Label != "" {
|
if req.Copyright == "" && extMeta.Copyright != "" {
|
||||||
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label)
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]string{
|
result := buildDeezerExtendedMetadataResult(metadata)
|
||||||
"genre": metadata.Genre,
|
|
||||||
"label": metadata.Label,
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(result)
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1337,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(track)
|
result := buildDeezerISRCSearchResult(track)
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -1345,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
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) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -1623,6 +1694,8 @@ func ExtractCoverToFile(audioPath string, outputPath string) error {
|
|||||||
|
|
||||||
if strings.HasSuffix(lower, ".flac") {
|
if strings.HasSuffix(lower, ".flac") {
|
||||||
coverData, err = ExtractCoverArt(audioPath)
|
coverData, err = ExtractCoverArt(audioPath)
|
||||||
|
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||||
|
coverData, err = extractCoverFromM4A(audioPath)
|
||||||
} else if strings.HasSuffix(lower, ".mp3") {
|
} else if strings.HasSuffix(lower, ".mp3") {
|
||||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
} 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)
|
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get extended metadata (genre, label) from Deezer if not already set
|
// Try to get extended metadata from Deezer if not already set
|
||||||
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -1815,7 +1888,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
if req.Label == "" && extMeta.Label != "" {
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
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)
|
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Deezer extended metadata for genre/label if we have ISRC
|
// Try Deezer extended metadata if we have ISRC
|
||||||
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
if req.ISRC != "" &&
|
||||||
|
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||||
cancel()
|
cancel()
|
||||||
@@ -1077,7 +1078,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if req.Label == "" && extMeta.Label != "" {
|
if req.Label == "" && extMeta.Label != "" {
|
||||||
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)
|
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||||
|
|
||||||
if isBuiltInProvider(providerIDNormalized) {
|
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)
|
GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
@@ -1264,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
req.Label = extMeta.Label
|
req.Label = extMeta.Label
|
||||||
GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.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 {
|
} else if err != nil {
|
||||||
GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err)
|
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)
|
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") {
|
if strings.HasSuffix(lower, ".mp3") {
|
||||||
meta, err := ReadID3Tags(filePath)
|
meta, err := ReadID3Tags(filePath)
|
||||||
if err == nil && meta != nil {
|
if err == nil && meta != nil {
|
||||||
@@ -581,6 +589,153 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return extractLyricsFromSidecarLRC(filePath)
|
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) {
|
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||||
ext := filepath.Ext(filePath)
|
ext := filepath.Ext(filePath)
|
||||||
base := strings.TrimSuffix(filePath, ext)
|
base := strings.TrimSuffix(filePath, ext)
|
||||||
@@ -743,15 +898,28 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, err
|
return AudioQuality{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
buf := make([]byte, 24)
|
buf := make([]byte, 32)
|
||||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
// AudioSampleEntry layout from the box type field:
|
||||||
bitDepth := 16
|
// [0:4] type ("mp4a"/"alac")
|
||||||
if atomType == "alac" {
|
// [4:10] SampleEntry.reserved
|
||||||
bitDepth = 24
|
// [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
|
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 {
|
if bestIdx >= 0 {
|
||||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
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 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||||
}
|
}
|
||||||
return absolute, bestType, nil
|
return absolute, bestType, nil
|
||||||
|
|||||||
+24
-5
@@ -54,6 +54,7 @@ const (
|
|||||||
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||||
|
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||||
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
|
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
|
||||||
qobuzDebugKeyXORMask = byte(0x5A)
|
qobuzDebugKeyXORMask = byte(0x5A)
|
||||||
)
|
)
|
||||||
@@ -1019,6 +1020,10 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
|
|||||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||||
return []string{
|
return []string{
|
||||||
qobuzDownloadAPIURL,
|
qobuzDownloadAPIURL,
|
||||||
|
qobuzDabMusicAPIURL,
|
||||||
|
qobuzDeebAPIURL,
|
||||||
|
qobuzAfkarAPIURL,
|
||||||
|
qobuzSquidAPIURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,6 +1044,8 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
|||||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||||
// "deeb" is mapped from the legacy reference fallback endpoint.
|
// "deeb" is mapped from the legacy reference fallback endpoint.
|
||||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
{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},
|
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2156,6 +2163,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
if req.AlbumName != "" {
|
if req.AlbumName != "" {
|
||||||
albumName = req.AlbumName
|
albumName = req.AlbumName
|
||||||
}
|
}
|
||||||
|
releaseDate := track.Album.ReleaseDate
|
||||||
|
if req.ReleaseDate != "" {
|
||||||
|
releaseDate = req.ReleaseDate
|
||||||
|
}
|
||||||
|
|
||||||
actualTrackNumber := req.TrackNumber
|
actualTrackNumber := req.TrackNumber
|
||||||
if actualTrackNumber == 0 {
|
if actualTrackNumber == 0 {
|
||||||
@@ -2167,7 +2178,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: albumName,
|
Album: albumName,
|
||||||
AlbumArtist: req.AlbumArtist,
|
AlbumArtist: req.AlbumArtist,
|
||||||
Date: track.Album.ReleaseDate,
|
Date: releaseDate,
|
||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: actualTrackNumber,
|
||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: req.DiscNumber,
|
||||||
@@ -2231,16 +2242,24 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
lyricsLRC = parallelResult.LyricsLRC
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
||||||
|
req,
|
||||||
|
track.Album.Title,
|
||||||
|
track.Album.ReleaseDate,
|
||||||
|
actualTrackNumber,
|
||||||
|
req.DiscNumber,
|
||||||
|
)
|
||||||
|
|
||||||
return QobuzDownloadResult{
|
return QobuzDownloadResult{
|
||||||
FilePath: outputPath,
|
FilePath: outputPath,
|
||||||
BitDepth: actualBitDepth,
|
BitDepth: actualBitDepth,
|
||||||
SampleRate: actualSampleRate,
|
SampleRate: actualSampleRate,
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Performer.Name,
|
Artist: track.Performer.Name,
|
||||||
Album: track.Album.Title,
|
Album: resultAlbum,
|
||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: resultReleaseDate,
|
||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: resultTrackNumber,
|
||||||
DiscNumber: req.DiscNumber,
|
DiscNumber: resultDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
LyricsLRC: lyricsLRC,
|
LyricsLRC: lyricsLRC,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -213,14 +213,16 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
|||||||
|
|
||||||
func TestQobuzAvailableProviders(t *testing.T) {
|
func TestQobuzAvailableProviders(t *testing.T) {
|
||||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||||
if len(providers) != 3 {
|
if len(providers) != 5 {
|
||||||
t.Fatalf("expected 3 Qobuz providers, got %d", len(providers))
|
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||||
}
|
}
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"musicdl": qobuzAPIKindMusicDL,
|
"musicdl": qobuzAPIKindMusicDL,
|
||||||
"dabmusic": qobuzAPIKindStandard,
|
"dabmusic": qobuzAPIKindStandard,
|
||||||
"deeb": qobuzAPIKindStandard,
|
"deeb": qobuzAPIKindStandard,
|
||||||
|
"qbz": qobuzAPIKindStandard,
|
||||||
|
"squid": qobuzAPIKindStandard,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
|
||||||
|
|
||||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||||
|
|||||||
+43
-39
@@ -1911,6 +1911,32 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
|||||||
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the resolved track matches the request.
|
||||||
|
actualTrack, fetchErr := downloader.getPublicTrack(strconv.FormatInt(trackID, 10))
|
||||||
|
if fetchErr != nil {
|
||||||
|
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
|
||||||
|
// Continue without verification — better than failing entirely.
|
||||||
|
} else {
|
||||||
|
providerArtist := actualTrack.Artist.Name
|
||||||
|
if providerArtist == "" && len(actualTrack.Artists) > 0 {
|
||||||
|
providerArtist = actualTrack.Artists[0].Name
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: actualTrack.Title,
|
||||||
|
ArtistName: providerArtist,
|
||||||
|
Duration: actualTrack.Duration,
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(req, resolved, logPrefix) {
|
||||||
|
// Invalidate the cached ID so future requests don't reuse it.
|
||||||
|
if req.ISRC != "" {
|
||||||
|
GetTrackIDCache().SetTidal(req.ISRC, 0)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("tidal track %d does not match request: expected '%s - %s', got '%s - %s'",
|
||||||
|
trackID, req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||||
|
}
|
||||||
|
GoLog("[%s] Track %d verified: '%s - %s' ✓\n", logPrefix, trackID, resolved.ArtistName, resolved.Title)
|
||||||
|
}
|
||||||
|
|
||||||
track := &TidalTrack{
|
track := &TidalTrack{
|
||||||
ID: trackID,
|
ID: trackID,
|
||||||
Title: strings.TrimSpace(req.TrackName),
|
Title: strings.TrimSpace(req.TrackName),
|
||||||
@@ -1961,11 +1987,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
|
|
||||||
outputExt := strings.TrimSpace(req.OutputExt)
|
outputExt := strings.TrimSpace(req.OutputExt)
|
||||||
if outputExt == "" {
|
if outputExt == "" {
|
||||||
if quality == "HIGH" {
|
outputExt = ".flac"
|
||||||
outputExt = ".m4a"
|
|
||||||
} else {
|
|
||||||
outputExt = ".flac"
|
|
||||||
}
|
|
||||||
} else if !strings.HasPrefix(outputExt, ".") {
|
} else if !strings.HasPrefix(outputExt, ".") {
|
||||||
outputExt = "." + outputExt
|
outputExt = "." + outputExt
|
||||||
}
|
}
|
||||||
@@ -1979,7 +2001,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
m4aPath = outputPath
|
m4aPath = outputPath
|
||||||
} else {
|
} else {
|
||||||
if outputExt == ".m4a" || quality == "HIGH" {
|
if outputExt == ".m4a" {
|
||||||
filename = sanitizeFilename(filename) + ".m4a"
|
filename = sanitizeFilename(filename) + ".m4a"
|
||||||
outputPath = filepath.Join(req.OutputDir, filename)
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
m4aPath = outputPath
|
m4aPath = outputPath
|
||||||
@@ -1992,10 +2014,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
if quality != "HIGH" {
|
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2151,27 +2171,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||||
}
|
}
|
||||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||||
if quality == "HIGH" {
|
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||||
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
|
||||||
|
|
||||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsMode := req.LyricsMode
|
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
|
||||||
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
|
||||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
|
||||||
} else {
|
|
||||||
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isSafOutput {
|
if !isSafOutput {
|
||||||
@@ -2181,24 +2181,28 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
bitDepth := downloadInfo.BitDepth
|
bitDepth := downloadInfo.BitDepth
|
||||||
sampleRate := downloadInfo.SampleRate
|
sampleRate := downloadInfo.SampleRate
|
||||||
lyricsLRC := ""
|
lyricsLRC := ""
|
||||||
if quality == "HIGH" {
|
|
||||||
bitDepth = 0
|
|
||||||
sampleRate = 44100
|
|
||||||
}
|
|
||||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
lyricsLRC = parallelResult.LyricsLRC
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata(
|
||||||
|
req,
|
||||||
|
track.Album.Title,
|
||||||
|
track.Album.ReleaseDate,
|
||||||
|
actualTrackNumber,
|
||||||
|
actualDiscNumber,
|
||||||
|
)
|
||||||
|
|
||||||
return TidalDownloadResult{
|
return TidalDownloadResult{
|
||||||
FilePath: actualOutputPath,
|
FilePath: actualOutputPath,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Title: track.Title,
|
Title: track.Title,
|
||||||
Artist: track.Artist.Name,
|
Artist: track.Artist.Name,
|
||||||
Album: track.Album.Title,
|
Album: resultAlbum,
|
||||||
ReleaseDate: track.Album.ReleaseDate,
|
ReleaseDate: resultReleaseDate,
|
||||||
TrackNumber: actualTrackNumber,
|
TrackNumber: resultTrackNumber,
|
||||||
DiscNumber: actualDiscNumber,
|
DiscNumber: resultDiscNumber,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
LyricsLRC: lyricsLRC,
|
LyricsLRC: lyricsLRC,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -68,3 +68,45 @@ func normalizeSymbolOnlyTitle(title string) string {
|
|||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Shared Track Verification ====================
|
||||||
|
|
||||||
|
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
|
||||||
|
type resolvedTrackInfo struct {
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
Duration int // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackMatchesRequest checks whether a resolved track from a provider matches
|
||||||
|
// the original download request. Returns true if the track is a plausible match.
|
||||||
|
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
|
||||||
|
if req.ArtistName != "" && resolved.ArtistName != "" &&
|
||||||
|
!artistsMatch(req.ArtistName, resolved.ArtistName) {
|
||||||
|
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
|
||||||
|
logPrefix, req.ArtistName, resolved.ArtistName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TrackName != "" && resolved.Title != "" &&
|
||||||
|
!titlesMatch(req.TrackName, resolved.Title) {
|
||||||
|
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
|
||||||
|
logPrefix, req.TrackName, resolved.Title)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
if expectedDurationSec > 0 && resolved.Duration > 0 {
|
||||||
|
diff := expectedDurationSec - resolved.Duration
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff > 10 {
|
||||||
|
GoLog("[%s] Verification failed: duration mismatch — expected %ds, got %ds\n",
|
||||||
|
logPrefix, expectedDurationSec, resolved.Duration)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ var (
|
|||||||
type YouTubeQuality string
|
type YouTubeQuality string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
||||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||||
@@ -37,7 +38,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
youtubeOpusSupportedBitrates = []int{128, 256}
|
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
||||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,6 +147,8 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize
|
|||||||
switch normalizedRaw {
|
switch normalizedRaw {
|
||||||
case "opus_256", "opus256", "opus":
|
case "opus_256", "opus256", "opus":
|
||||||
return "opus", 256, YouTubeQualityOpus256
|
return "opus", 256, YouTubeQualityOpus256
|
||||||
|
case "opus_320", "opus320":
|
||||||
|
return "opus", 320, YouTubeQualityOpus320
|
||||||
case "opus_128", "opus128":
|
case "opus_128", "opus128":
|
||||||
return "opus", 128, YouTubeQualityOpus128
|
return "opus", 128, YouTubeQualityOpus128
|
||||||
case "mp3_320", "mp3320", "mp3", "":
|
case "mp3_320", "mp3320", "mp3", "":
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T)
|
|||||||
|
|
||||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||||
if opusBitrate != 256 {
|
if opusBitrate != 320 {
|
||||||
t.Fatalf("expected opus normalization to 256, got %d", opusBitrate)
|
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||||
@@ -39,3 +39,16 @@ func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
|||||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
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
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.8.0';
|
static const String version = '3.8.6';
|
||||||
static const String buildNumber = '106';
|
static const String buildNumber = '112';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
|
|||||||
+251
-68
@@ -1066,6 +1066,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Import'**
|
/// **'Import'**
|
||||||
String get dialogImport;
|
String get dialogImport;
|
||||||
|
|
||||||
|
/// Confirm button in Download All dialog
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Download'**
|
||||||
|
String get dialogDownload;
|
||||||
|
|
||||||
/// Dialog button - discard changes
|
/// Dialog button - discard changes
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3100,6 +3106,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Show when searching for existing tracks'**
|
/// **'Show when searching for existing tracks'**
|
||||||
String get libraryShowDuplicateIndicatorSubtitle;
|
String get libraryShowDuplicateIndicatorSubtitle;
|
||||||
|
|
||||||
|
/// Setting for automatic library scanning
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Auto Scan'**
|
||||||
|
String get libraryAutoScan;
|
||||||
|
|
||||||
|
/// Subtitle for auto scan setting
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Automatically scan your library for new files'**
|
||||||
|
String get libraryAutoScanSubtitle;
|
||||||
|
|
||||||
|
/// Auto scan disabled
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Off'**
|
||||||
|
String get libraryAutoScanOff;
|
||||||
|
|
||||||
|
/// Auto scan when app opens
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Every app open'**
|
||||||
|
String get libraryAutoScanOnOpen;
|
||||||
|
|
||||||
|
/// Auto scan once per day
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Daily'**
|
||||||
|
String get libraryAutoScanDaily;
|
||||||
|
|
||||||
|
/// Auto scan once per week
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Weekly'**
|
||||||
|
String get libraryAutoScanWeekly;
|
||||||
|
|
||||||
/// Section header for library actions
|
/// Section header for library actions
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3832,6 +3874,36 @@ abstract class AppLocalizations {
|
|||||||
/// **'FFmpeg metadata embed failed'**
|
/// **'FFmpeg metadata embed failed'**
|
||||||
String get trackReEnrichFfmpegFailed;
|
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
|
/// Snackbar when save operation fails
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -3847,7 +3919,7 @@ abstract class AppLocalizations {
|
|||||||
/// Subtitle for convert format menu item
|
/// Subtitle for convert format menu item
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Convert to MP3 or Opus'**
|
/// **'Convert to MP3, Opus, ALAC, or FLAC'**
|
||||||
String get trackConvertFormatSubtitle;
|
String get trackConvertFormatSubtitle;
|
||||||
|
|
||||||
/// Title of convert bottom sheet
|
/// Title of convert bottom sheet
|
||||||
@@ -3884,6 +3956,21 @@ abstract class AppLocalizations {
|
|||||||
String bitrate,
|
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
|
/// Snackbar while converting
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4254,6 +4341,12 @@ abstract class AppLocalizations {
|
|||||||
String bitrate,
|
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
|
/// Snackbar during batch conversion progress
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4434,7 +4527,7 @@ abstract class AppLocalizations {
|
|||||||
/// **'Added {count} tracks to Loved'**
|
/// **'Added {count} tracks to Loved'**
|
||||||
String snackbarAddedTracksToLoved(int count);
|
String snackbarAddedTracksToLoved(int count);
|
||||||
|
|
||||||
/// Title of the Download All confirmation dialog
|
/// Dialog title for bulk download confirmation
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Download All'**
|
/// **'Download All'**
|
||||||
@@ -4446,12 +4539,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Download {count} tracks?'**
|
/// **'Download {count} tracks?'**
|
||||||
String dialogDownloadAllMessage(int count);
|
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
|
/// Checkbox label in import dialog to skip already-downloaded songs
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4602,18 +4689,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Select a built-in service to enable'**
|
/// **'Select a built-in service to enable'**
|
||||||
String get downloadSelectServiceToEnable;
|
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
|
/// Info hint when non-Tidal/Qobuz service is selected
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4740,54 +4815,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Auto'**
|
/// **'Auto'**
|
||||||
String get downloadMusixmatchAuto;
|
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
|
/// Subtitle for 'Any' network mode option
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -4817,6 +4844,162 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Refresh'**
|
/// **'Refresh'**
|
||||||
String get cacheRefresh;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -536,6 +536,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Importieren';
|
String get dialogImport => 'Importieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Verwerfen';
|
String get dialogDiscard => 'Verwerfen';
|
||||||
|
|
||||||
@@ -1716,6 +1719,25 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Aktionen';
|
String get libraryActions => 'Aktionen';
|
||||||
|
|
||||||
@@ -2169,6 +2191,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get trackReEnrichFfmpegFailed =>
|
String get trackReEnrichFfmpegFailed =>
|
||||||
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
|
'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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Fehler: $error';
|
return 'Fehler: $error';
|
||||||
@@ -2201,6 +2245,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Konvertieren von $sourceFormat in $targetFormat bei $bitrate?\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Konvertiere Audio...';
|
String get trackConvertConverting => 'Konvertiere Audio...';
|
||||||
|
|
||||||
@@ -2455,6 +2511,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Konvertiere $count $format $_temp0 zu $bitrate?\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Konvertiere $current von $total...';
|
return 'Konvertiere $current von $total...';
|
||||||
@@ -2580,9 +2647,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2666,12 +2730,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2748,32 +2806,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2790,4 +2822,124 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle =>
|
||||||
|
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2762,4 +2795,124 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle =>
|
||||||
|
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2762,6 +2795,126 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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`).
|
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1694,6 +1697,25 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2144,6 +2166,28 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2176,6 +2220,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2429,6 +2485,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2554,9 +2621,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2640,12 +2704,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2722,32 +2780,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2764,4 +2796,124 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2174,6 +2218,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2427,6 +2483,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2552,9 +2619,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2638,12 +2702,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2720,32 +2778,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2762,4 +2794,124 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'Impor';
|
String get dialogImport => 'Impor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Buang';
|
String get dialogDiscard => 'Buang';
|
||||||
|
|
||||||
@@ -1699,6 +1702,25 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2149,6 +2171,28 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2158,7 +2202,8 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle =>
|
||||||
|
'Konversi ke MP3, Opus, ALAC, atau FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
@@ -2181,6 +2226,18 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2434,6 +2491,17 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2559,9 +2627,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2645,12 +2710,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2727,32 +2786,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2769,4 +2802,124 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'インポート';
|
String get dialogImport => 'インポート';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => '破棄';
|
String get dialogDiscard => '破棄';
|
||||||
|
|
||||||
@@ -1679,6 +1682,25 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'アクション';
|
String get libraryActions => 'アクション';
|
||||||
|
|
||||||
@@ -2129,6 +2151,28 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return '失敗: $error';
|
return '失敗: $error';
|
||||||
@@ -2161,6 +2205,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'オーディオを変換中...';
|
String get trackConvertConverting => 'オーディオを変換中...';
|
||||||
|
|
||||||
@@ -2414,6 +2470,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2539,9 +2606,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2625,12 +2689,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2707,32 +2765,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2749,4 +2781,124 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => '불러오기';
|
String get dialogImport => '불러오기';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => '취소';
|
String get dialogDiscard => '취소';
|
||||||
|
|
||||||
@@ -1672,6 +1675,25 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2122,6 +2144,28 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2154,6 +2198,18 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2407,6 +2463,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2532,9 +2599,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2618,12 +2682,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2700,32 +2758,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2742,4 +2774,124 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2174,6 +2218,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2427,6 +2483,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2552,9 +2619,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2638,12 +2702,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2720,32 +2778,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2762,4 +2794,124 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle =>
|
||||||
|
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2762,6 +2795,126 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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`).
|
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
|
||||||
|
|||||||
@@ -534,6 +534,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogImport => 'Импорт';
|
String get dialogImport => 'Импорт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Отменить';
|
String get dialogDiscard => 'Отменить';
|
||||||
|
|
||||||
@@ -1728,6 +1731,25 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Показать при поиске существующих треков';
|
'Показать при поиске существующих треков';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Действия';
|
String get libraryActions => 'Действия';
|
||||||
|
|
||||||
@@ -2195,6 +2217,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get trackReEnrichFfmpegFailed =>
|
String get trackReEnrichFfmpegFailed =>
|
||||||
'Ошибка встраивания метаданных FFmpeg';
|
'Ошибка встраивания метаданных 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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Ошибка: $error';
|
return 'Ошибка: $error';
|
||||||
@@ -2227,6 +2271,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Конвертировать из $sourceFormat в $targetFormat $bitrate?\n\nОригинальный файл будет удален после конвертации.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Конвертация аудио...';
|
String get trackConvertConverting => 'Конвертация аудио...';
|
||||||
|
|
||||||
@@ -2486,6 +2542,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Конвертация $current из $total...';
|
return 'Конвертация $current из $total...';
|
||||||
@@ -2611,9 +2678,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2697,12 +2761,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2779,32 +2837,6 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2821,4 +2853,124 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'İçe aktar';
|
String get dialogImport => 'İçe aktar';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Vazgeç';
|
String get dialogDiscard => 'Vazgeç';
|
||||||
|
|
||||||
@@ -1704,6 +1707,25 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2154,6 +2176,28 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2186,6 +2230,18 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2439,6 +2495,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2564,9 +2631,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2650,12 +2714,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2732,32 +2790,6 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2774,4 +2806,124 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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
|
@override
|
||||||
String get dialogImport => 'Import';
|
String get dialogImport => 'Import';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -1692,6 +1695,25 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||||
'Show when searching for existing tracks';
|
'Show when searching for existing tracks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScan => 'Auto Scan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanSubtitle =>
|
||||||
|
'Automatically scan your library for new files';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOff => 'Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanOnOpen => 'Every app open';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanDaily => 'Daily';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryAutoScanWeekly => 'Weekly';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryActions => 'Actions';
|
String get libraryActions => 'Actions';
|
||||||
|
|
||||||
@@ -2142,6 +2164,28 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
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
|
@override
|
||||||
String trackSaveFailed(String error) {
|
String trackSaveFailed(String error) {
|
||||||
return 'Failed: $error';
|
return 'Failed: $error';
|
||||||
@@ -2151,7 +2195,8 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get trackConvertFormat => 'Convert Format';
|
String get trackConvertFormat => 'Convert Format';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
String get trackConvertFormatSubtitle =>
|
||||||
|
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get trackConvertTitle => 'Convert Audio';
|
String get trackConvertTitle => 'Convert Audio';
|
||||||
@@ -2174,6 +2219,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
|
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
|
@override
|
||||||
String get trackConvertConverting => 'Converting audio...';
|
String get trackConvertConverting => 'Converting audio...';
|
||||||
|
|
||||||
@@ -2427,6 +2484,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
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
|
@override
|
||||||
String selectionBatchConvertProgress(int current, int total) {
|
String selectionBatchConvertProgress(int current, int total) {
|
||||||
return 'Converting $current of $total...';
|
return 'Converting $current of $total...';
|
||||||
@@ -2552,9 +2620,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return 'Download $count tracks?';
|
return 'Download $count tracks?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get dialogDownload => 'Download';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||||
|
|
||||||
@@ -2638,12 +2703,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select a built-in service to enable';
|
'Select a built-in service to enable';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossy320 => 'Lossy 320kbps';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadLossyFormat => 'Lossy Format';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz above to configure quality';
|
'Select Tidal or Qobuz above to configure quality';
|
||||||
@@ -2720,32 +2779,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadMusixmatchAuto => 'Auto';
|
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
|
@override
|
||||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||||
|
|
||||||
@@ -2762,6 +2795,126 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheRefresh => 'Refresh';
|
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`).
|
/// The translations for Chinese, as used in China (`zh_CN`).
|
||||||
|
|||||||
+231
-41
@@ -671,6 +671,10 @@
|
|||||||
"@dialogImport": {
|
"@dialogImport": {
|
||||||
"description": "Dialog button - import data"
|
"description": "Dialog button - import data"
|
||||||
},
|
},
|
||||||
|
"dialogDownload": "Download",
|
||||||
|
"@dialogDownload": {
|
||||||
|
"description": "Dialog button - download action"
|
||||||
|
},
|
||||||
"dialogDiscard": "Discard",
|
"dialogDiscard": "Discard",
|
||||||
"@dialogDiscard": {
|
"@dialogDiscard": {
|
||||||
"description": "Dialog button - discard changes"
|
"description": "Dialog button - discard changes"
|
||||||
@@ -2238,6 +2242,30 @@
|
|||||||
"@libraryShowDuplicateIndicatorSubtitle": {
|
"@libraryShowDuplicateIndicatorSubtitle": {
|
||||||
"description": "Subtitle for duplicate indicator toggle"
|
"description": "Subtitle for duplicate indicator toggle"
|
||||||
},
|
},
|
||||||
|
"libraryAutoScan": "Auto Scan",
|
||||||
|
"@libraryAutoScan": {
|
||||||
|
"description": "Setting for automatic library scanning"
|
||||||
|
},
|
||||||
|
"libraryAutoScanSubtitle": "Automatically scan your library for new files",
|
||||||
|
"@libraryAutoScanSubtitle": {
|
||||||
|
"description": "Subtitle for auto scan setting"
|
||||||
|
},
|
||||||
|
"libraryAutoScanOff": "Off",
|
||||||
|
"@libraryAutoScanOff": {
|
||||||
|
"description": "Auto scan disabled"
|
||||||
|
},
|
||||||
|
"libraryAutoScanOnOpen": "Every app open",
|
||||||
|
"@libraryAutoScanOnOpen": {
|
||||||
|
"description": "Auto scan when app opens"
|
||||||
|
},
|
||||||
|
"libraryAutoScanDaily": "Daily",
|
||||||
|
"@libraryAutoScanDaily": {
|
||||||
|
"description": "Auto scan once per day"
|
||||||
|
},
|
||||||
|
"libraryAutoScanWeekly": "Weekly",
|
||||||
|
"@libraryAutoScanWeekly": {
|
||||||
|
"description": "Auto scan once per week"
|
||||||
|
},
|
||||||
"libraryActions": "Actions",
|
"libraryActions": "Actions",
|
||||||
"@libraryActions": {
|
"@libraryActions": {
|
||||||
"description": "Section header for library actions"
|
"description": "Section header for library actions"
|
||||||
@@ -2815,6 +2843,47 @@
|
|||||||
"@trackReEnrichFfmpegFailed": {
|
"@trackReEnrichFfmpegFailed": {
|
||||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
"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": "Failed: {error}",
|
||||||
"@trackSaveFailed": {
|
"@trackSaveFailed": {
|
||||||
"description": "Snackbar when save operation fails",
|
"description": "Snackbar when save operation fails",
|
||||||
@@ -2828,7 +2897,7 @@
|
|||||||
"@trackConvertFormat": {
|
"@trackConvertFormat": {
|
||||||
"description": "Menu item - convert audio format"
|
"description": "Menu item - convert audio format"
|
||||||
},
|
},
|
||||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
|
||||||
"@trackConvertFormatSubtitle": {
|
"@trackConvertFormatSubtitle": {
|
||||||
"description": "Subtitle for convert format menu item"
|
"description": "Subtitle for convert format menu item"
|
||||||
},
|
},
|
||||||
@@ -2863,6 +2932,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": "Converting audio...",
|
||||||
"@trackConvertConverting": {
|
"@trackConvertConverting": {
|
||||||
"description": "Snackbar while converting"
|
"description": "Snackbar while converting"
|
||||||
@@ -3214,6 +3299,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": "Converting {current} of {total}...",
|
||||||
"@selectionBatchConvertProgress": {
|
"@selectionBatchConvertProgress": {
|
||||||
"description": "Snackbar during batch conversion progress",
|
"description": "Snackbar during batch conversion progress",
|
||||||
@@ -3509,14 +3606,7 @@
|
|||||||
"@downloadSelectServiceToEnable": {
|
"@downloadSelectServiceToEnable": {
|
||||||
"description": "Hint shown instead of Ask-quality subtitle when no built-in service selected"
|
"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": "Select Tidal or Qobuz above to configure quality",
|
||||||
"@downloadSelectTidalQobuz": {
|
"@downloadSelectTidalQobuz": {
|
||||||
"description": "Info hint when non-Tidal/Qobuz service is selected"
|
"description": "Info hint when non-Tidal/Qobuz service is selected"
|
||||||
@@ -3602,38 +3692,7 @@
|
|||||||
"@downloadMusixmatchAuto": {
|
"@downloadMusixmatchAuto": {
|
||||||
"description": "Button to reset Musixmatch language to automatic"
|
"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": "WiFi + Mobile Data",
|
||||||
"@downloadNetworkAnySubtitle": {
|
"@downloadNetworkAnySubtitle": {
|
||||||
"description": "Subtitle for 'Any' network mode option"
|
"description": "Subtitle for 'Any' network mode option"
|
||||||
@@ -3657,5 +3716,136 @@
|
|||||||
"cacheRefresh": "Refresh",
|
"cacheRefresh": "Refresh",
|
||||||
"@cacheRefresh": {
|
"@cacheRefresh": {
|
||||||
"description": "Tooltip for refresh button on cache management page"
|
"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": {
|
"@trackReEnrichFfmpegFailed": {
|
||||||
"description": "Snackbar when FFmpeg embed fails for MP3/Opus"
|
"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": "Failed: {error}",
|
||||||
"@trackSaveFailed": {
|
"@trackSaveFailed": {
|
||||||
"description": "Snackbar when save operation fails",
|
"description": "Snackbar when save operation fails",
|
||||||
@@ -2768,7 +2809,7 @@
|
|||||||
"@trackConvertFormat": {
|
"@trackConvertFormat": {
|
||||||
"description": "Menu item - convert audio format"
|
"description": "Menu item - convert audio format"
|
||||||
},
|
},
|
||||||
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
|
"trackConvertFormatSubtitle": "Konversi ke MP3, Opus, ALAC, atau FLAC",
|
||||||
"@trackConvertFormatSubtitle": {
|
"@trackConvertFormatSubtitle": {
|
||||||
"description": "Subtitle for convert format menu item"
|
"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": "Converting audio...",
|
||||||
"@trackConvertConverting": {
|
"@trackConvertConverting": {
|
||||||
"description": "Snackbar while converting"
|
"description": "Snackbar while converting"
|
||||||
@@ -3114,4 +3171,4 @@
|
|||||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||||
"description": "Subtitle when Track Artist is used for folder naming"
|
"description": "Subtitle when Track Artist is used for folder naming"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-2
@@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotiflac_android/app.dart';
|
import 'package:spotiflac_android/app.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
@@ -90,16 +91,21 @@ class _EagerInitialization extends ConsumerStatefulWidget {
|
|||||||
_EagerInitializationState();
|
_EagerInitializationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
ProviderSubscription<bool>? _localLibraryEnabledSub;
|
ProviderSubscription<bool>? _localLibraryEnabledSub;
|
||||||
Timer? _downloadHistoryWarmupTimer;
|
Timer? _downloadHistoryWarmupTimer;
|
||||||
Timer? _libraryCollectionsWarmupTimer;
|
Timer? _libraryCollectionsWarmupTimer;
|
||||||
Timer? _localLibraryWarmupTimer;
|
Timer? _localLibraryWarmupTimer;
|
||||||
bool _localLibraryWarmupScheduled = false;
|
bool _localLibraryWarmupScheduled = false;
|
||||||
|
bool _autoScanTriggeredOnLaunch = false;
|
||||||
|
|
||||||
|
static const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_initializeAppServices();
|
_initializeAppServices();
|
||||||
@@ -110,6 +116,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_localLibraryEnabledSub?.close();
|
_localLibraryEnabledSub?.close();
|
||||||
_downloadHistoryWarmupTimer?.cancel();
|
_downloadHistoryWarmupTimer?.cancel();
|
||||||
_libraryCollectionsWarmupTimer?.cancel();
|
_libraryCollectionsWarmupTimer?.cancel();
|
||||||
@@ -117,6 +124,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_maybeAutoScanLocalLibrary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _initializeDeferredProviders() {
|
void _initializeDeferredProviders() {
|
||||||
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
|
_downloadHistoryWarmupTimer = _scheduleProviderWarmup(
|
||||||
const Duration(milliseconds: 400),
|
const Duration(milliseconds: 400),
|
||||||
@@ -155,7 +169,64 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
_localLibraryWarmupScheduled = true;
|
_localLibraryWarmupScheduled = true;
|
||||||
_localLibraryWarmupTimer = _scheduleProviderWarmup(
|
_localLibraryWarmupTimer = _scheduleProviderWarmup(
|
||||||
const Duration(milliseconds: 1600),
|
const Duration(milliseconds: 1600),
|
||||||
() => ref.read(localLibraryProvider),
|
() {
|
||||||
|
ref.read(localLibraryProvider);
|
||||||
|
// Trigger auto-scan after initial warmup on first app launch.
|
||||||
|
if (!_autoScanTriggeredOnLaunch) {
|
||||||
|
_autoScanTriggeredOnLaunch = true;
|
||||||
|
// Give the provider a moment to load existing data before scanning.
|
||||||
|
Future.delayed(const Duration(milliseconds: 500), () {
|
||||||
|
if (mounted) _maybeAutoScanLocalLibrary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether an automatic incremental scan should be triggered based on
|
||||||
|
/// the user's auto-scan preference and the time since the last scan.
|
||||||
|
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
if (!settings.localLibraryEnabled) return;
|
||||||
|
if (settings.localLibraryPath.isEmpty) return;
|
||||||
|
if (settings.localLibraryAutoScan == 'off') return;
|
||||||
|
|
||||||
|
// Don't start a scan if one is already running.
|
||||||
|
final libraryState = ref.read(localLibraryProvider);
|
||||||
|
if (libraryState.isScanning) return;
|
||||||
|
|
||||||
|
// Determine cooldown based on auto-scan mode.
|
||||||
|
final now = DateTime.now();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final lastScannedMs = prefs.getInt(_lastScannedAtKey);
|
||||||
|
|
||||||
|
if (lastScannedMs != null) {
|
||||||
|
final lastScanned = DateTime.fromMillisecondsSinceEpoch(lastScannedMs);
|
||||||
|
final elapsed = now.difference(lastScanned);
|
||||||
|
|
||||||
|
switch (settings.localLibraryAutoScan) {
|
||||||
|
case 'on_open':
|
||||||
|
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||||
|
if (elapsed.inMinutes < 10) return;
|
||||||
|
break;
|
||||||
|
case 'daily':
|
||||||
|
if (elapsed.inHours < 24) return;
|
||||||
|
break;
|
||||||
|
case 'weekly':
|
||||||
|
if (elapsed.inDays < 7) return;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed -- start an incremental scan.
|
||||||
|
final iosBookmark = settings.localLibraryBookmark;
|
||||||
|
ref.read(localLibraryProvider.notifier).startScan(
|
||||||
|
settings.localLibraryPath,
|
||||||
|
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,8 @@ class AppSettings {
|
|||||||
final bool showExtensionStore;
|
final bool showExtensionStore;
|
||||||
final String locale;
|
final String locale;
|
||||||
final String lyricsMode;
|
final String lyricsMode;
|
||||||
final String
|
|
||||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
|
||||||
final int
|
final int
|
||||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256 kbps)
|
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
|
||||||
final int
|
final int
|
||||||
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
|
||||||
final bool
|
final bool
|
||||||
@@ -61,6 +59,8 @@ class AppSettings {
|
|||||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||||
final bool
|
final bool
|
||||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||||
|
final String
|
||||||
|
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||||
|
|
||||||
final bool
|
final bool
|
||||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||||
@@ -114,7 +114,6 @@ class AppSettings {
|
|||||||
this.showExtensionStore = true,
|
this.showExtensionStore = true,
|
||||||
this.locale = 'system',
|
this.locale = 'system',
|
||||||
this.lyricsMode = 'embed',
|
this.lyricsMode = 'embed',
|
||||||
this.tidalHighFormat = 'mp3_320',
|
|
||||||
this.youtubeOpusBitrate = 256,
|
this.youtubeOpusBitrate = 256,
|
||||||
this.youtubeMp3Bitrate = 320,
|
this.youtubeMp3Bitrate = 320,
|
||||||
this.useAllFilesAccess = false,
|
this.useAllFilesAccess = false,
|
||||||
@@ -126,6 +125,7 @@ class AppSettings {
|
|||||||
this.localLibraryPath = '',
|
this.localLibraryPath = '',
|
||||||
this.localLibraryBookmark = '',
|
this.localLibraryBookmark = '',
|
||||||
this.localLibraryShowDuplicates = true,
|
this.localLibraryShowDuplicates = true,
|
||||||
|
this.localLibraryAutoScan = 'off',
|
||||||
this.hasCompletedTutorial = false,
|
this.hasCompletedTutorial = false,
|
||||||
this.lyricsProviders = const [
|
this.lyricsProviders = const [
|
||||||
'lrclib',
|
'lrclib',
|
||||||
@@ -178,7 +178,6 @@ class AppSettings {
|
|||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
String? locale,
|
String? locale,
|
||||||
String? lyricsMode,
|
String? lyricsMode,
|
||||||
String? tidalHighFormat,
|
|
||||||
int? youtubeOpusBitrate,
|
int? youtubeOpusBitrate,
|
||||||
int? youtubeMp3Bitrate,
|
int? youtubeMp3Bitrate,
|
||||||
bool? useAllFilesAccess,
|
bool? useAllFilesAccess,
|
||||||
@@ -190,6 +189,7 @@ class AppSettings {
|
|||||||
String? localLibraryPath,
|
String? localLibraryPath,
|
||||||
String? localLibraryBookmark,
|
String? localLibraryBookmark,
|
||||||
bool? localLibraryShowDuplicates,
|
bool? localLibraryShowDuplicates,
|
||||||
|
String? localLibraryAutoScan,
|
||||||
bool? hasCompletedTutorial,
|
bool? hasCompletedTutorial,
|
||||||
List<String>? lyricsProviders,
|
List<String>? lyricsProviders,
|
||||||
bool? lyricsIncludeTranslationNetease,
|
bool? lyricsIncludeTranslationNetease,
|
||||||
@@ -241,7 +241,6 @@ class AppSettings {
|
|||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
locale: locale ?? this.locale,
|
locale: locale ?? this.locale,
|
||||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
|
||||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
||||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
||||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||||
@@ -256,6 +255,8 @@ class AppSettings {
|
|||||||
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||||
|
localLibraryAutoScan:
|
||||||
|
localLibraryAutoScan ?? this.localLibraryAutoScan,
|
||||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||||
lyricsIncludeTranslationNetease:
|
lyricsIncludeTranslationNetease:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
locale: json['locale'] as String? ?? 'system',
|
locale: json['locale'] as String? ?? 'system',
|
||||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
|
||||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
||||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
||||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||||
@@ -58,6 +57,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
localLibraryBookmark: json['localLibraryBookmark'] as String? ?? '',
|
||||||
localLibraryShowDuplicates:
|
localLibraryShowDuplicates:
|
||||||
json['localLibraryShowDuplicates'] as bool? ?? true,
|
json['localLibraryShowDuplicates'] as bool? ?? true,
|
||||||
|
localLibraryAutoScan: json['localLibraryAutoScan'] as String? ?? 'off',
|
||||||
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
|
||||||
lyricsProviders:
|
lyricsProviders:
|
||||||
(json['lyricsProviders'] as List<dynamic>?)
|
(json['lyricsProviders'] as List<dynamic>?)
|
||||||
@@ -119,7 +119,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
'locale': instance.locale,
|
'locale': instance.locale,
|
||||||
'lyricsMode': instance.lyricsMode,
|
'lyricsMode': instance.lyricsMode,
|
||||||
'tidalHighFormat': instance.tidalHighFormat,
|
|
||||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||||
@@ -131,6 +130,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
|||||||
'localLibraryPath': instance.localLibraryPath,
|
'localLibraryPath': instance.localLibraryPath,
|
||||||
'localLibraryBookmark': instance.localLibraryBookmark,
|
'localLibraryBookmark': instance.localLibraryBookmark,
|
||||||
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
|
||||||
|
'localLibraryAutoScan': instance.localLibraryAutoScan,
|
||||||
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
'hasCompletedTutorial': instance.hasCompletedTutorial,
|
||||||
'lyricsProviders': instance.lyricsProviders,
|
'lyricsProviders': instance.lyricsProviders,
|
||||||
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
'lyricsIncludeTranslationNetease': instance.lyricsIncludeTranslationNetease,
|
||||||
|
|||||||
@@ -770,6 +770,37 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
|
|
||||||
/// Remove history entries where the file no longer exists on disk
|
/// Remove history entries where the file no longer exists on disk
|
||||||
/// Returns the number of orphaned entries removed
|
/// Returns the number of orphaned entries removed
|
||||||
|
/// Audio file extensions that the app commonly produces or converts between.
|
||||||
|
static const _audioExtensions = [
|
||||||
|
'.flac',
|
||||||
|
'.m4a',
|
||||||
|
'.mp3',
|
||||||
|
'.opus',
|
||||||
|
'.ogg',
|
||||||
|
'.wav',
|
||||||
|
'.aac',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// When the original file is missing, check whether a sibling with a
|
||||||
|
/// different audio extension exists (e.g. the user converted .flac → .opus).
|
||||||
|
/// Returns the path of the first match found, or `null` if none exist.
|
||||||
|
Future<String?> _findConvertedSibling(String originalPath) async {
|
||||||
|
// Strip the current extension to get the base path.
|
||||||
|
final dotIndex = originalPath.lastIndexOf('.');
|
||||||
|
if (dotIndex < 0) return null;
|
||||||
|
final basePath = originalPath.substring(0, dotIndex);
|
||||||
|
final originalExt = originalPath.substring(dotIndex).toLowerCase();
|
||||||
|
|
||||||
|
for (final ext in _audioExtensions) {
|
||||||
|
if (ext == originalExt) continue;
|
||||||
|
final candidatePath = '$basePath$ext';
|
||||||
|
try {
|
||||||
|
if (await fileExists(candidatePath)) return candidatePath;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> cleanupOrphanedDownloads() async {
|
Future<int> cleanupOrphanedDownloads() async {
|
||||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||||
|
|
||||||
@@ -791,7 +822,21 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
if (filePath == null || filePath.isEmpty) return null;
|
if (filePath == null || filePath.isEmpty) return null;
|
||||||
pathById[id] = filePath;
|
pathById[id] = filePath;
|
||||||
try {
|
try {
|
||||||
return MapEntry(id, await fileExists(filePath));
|
if (await fileExists(filePath)) return MapEntry(id, true);
|
||||||
|
|
||||||
|
// Original file missing -- check for a converted sibling.
|
||||||
|
final sibling = await _findConvertedSibling(filePath);
|
||||||
|
if (sibling != null) {
|
||||||
|
_historyLog.i(
|
||||||
|
'Found converted sibling for $id: $filePath → $sibling',
|
||||||
|
);
|
||||||
|
// Update the stored path so future checks succeed immediately.
|
||||||
|
await _db.updateFilePath(id, sibling);
|
||||||
|
pathById[id] = sibling;
|
||||||
|
return MapEntry(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapEntry(id, false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_historyLog.w('Error checking file existence for $id: $e');
|
_historyLog.w('Error checking file existence for $id: $e');
|
||||||
return MapEntry(id, false);
|
return MapEntry(id, false);
|
||||||
@@ -1840,7 +1885,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return '.opus';
|
return '.opus';
|
||||||
}
|
}
|
||||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||||
return '.m4a';
|
return '.flac'; // HIGH quality no longer available; fallback to FLAC
|
||||||
}
|
}
|
||||||
return '.flac';
|
return '.flac';
|
||||||
}
|
}
|
||||||
@@ -2383,7 +2428,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
backendResult['album_artist'] as String?,
|
backendResult['album_artist'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
final hasOverrides = backendTrackNum != null ||
|
final hasOverrides =
|
||||||
|
backendTrackNum != null ||
|
||||||
backendDiscNum != null ||
|
backendDiscNum != null ||
|
||||||
backendYear != null ||
|
backendYear != null ||
|
||||||
backendAlbum != null ||
|
backendAlbum != null ||
|
||||||
@@ -3612,6 +3658,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!useSaf) {
|
||||||
|
await _ensureDirExists(outputDir, label: 'Output folder');
|
||||||
|
}
|
||||||
|
|
||||||
_log.d('Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
|
|
||||||
final normalizedTrackNumber =
|
final normalizedTrackNumber =
|
||||||
@@ -3903,7 +3954,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
isContentUriPath &&
|
isContentUriPath &&
|
||||||
effectiveSafMode &&
|
effectiveSafMode &&
|
||||||
actualService == 'tidal' &&
|
actualService == 'tidal' &&
|
||||||
quality != 'HIGH' &&
|
|
||||||
filePath.endsWith('.flac') &&
|
filePath.endsWith('.flac') &&
|
||||||
(mimeType == null || mimeType.contains('flac'));
|
(mimeType == null || mimeType.contains('flac'));
|
||||||
|
|
||||||
@@ -3918,73 +3968,50 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final currentFilePath = filePath;
|
final currentFilePath = filePath;
|
||||||
|
|
||||||
if (isContentUriPath && effectiveSafMode) {
|
if (isContentUriPath && effectiveSafMode) {
|
||||||
if (quality == 'HIGH') {
|
_log.d('M4A file detected (SAF), converting to FLAC...');
|
||||||
final tidalHighFormat = settings.tidalHighFormat;
|
final tempPath = await _copySafToTemp(currentFilePath);
|
||||||
_log.i(
|
if (tempPath != null) {
|
||||||
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
|
String? flacPath;
|
||||||
);
|
try {
|
||||||
|
final length = await File(tempPath).length();
|
||||||
final tempPath = await _copySafToTemp(currentFilePath);
|
if (length < 1024) {
|
||||||
if (tempPath != null) {
|
_log.w('Temp M4A is too small (<1KB), skipping conversion');
|
||||||
String? convertedPath;
|
} else {
|
||||||
try {
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.downloading,
|
DownloadStatus.downloading,
|
||||||
progress: 0.95,
|
progress: 0.95,
|
||||||
);
|
);
|
||||||
|
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
||||||
final format = tidalHighFormat.startsWith('opus')
|
if (flacPath != null) {
|
||||||
? 'opus'
|
_log.d('Converted to FLAC (temp): $flacPath');
|
||||||
: 'mp3';
|
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||||
convertedPath = await FFmpegService.convertM4aToLossy(
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||||
tempPath,
|
trackToDownload,
|
||||||
format: format,
|
result,
|
||||||
bitrate: tidalHighFormat,
|
resolvedAlbumArtist,
|
||||||
deleteOriginal: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (convertedPath != null) {
|
|
||||||
_log.i(
|
|
||||||
'Successfully converted M4A to $format (temp): $convertedPath',
|
|
||||||
);
|
|
||||||
_log.i('Embedding metadata to $format...');
|
|
||||||
updateItemStatus(
|
|
||||||
item.id,
|
|
||||||
DownloadStatus.downloading,
|
|
||||||
progress: 0.99,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final backendGenre = result['genre'] as String?;
|
final backendGenre = result['genre'] as String?;
|
||||||
final backendLabel = result['label'] as String?;
|
final backendLabel = result['label'] as String?;
|
||||||
final backendCopyright = result['copyright'] as String?;
|
final backendCopyright = result['copyright'] as String?;
|
||||||
|
|
||||||
if (format == 'mp3') {
|
await _embedMetadataAndCover(
|
||||||
await _embedMetadataToMp3(
|
flacPath,
|
||||||
convertedPath,
|
finalTrack,
|
||||||
trackToDownload,
|
genre: backendGenre ?? genre,
|
||||||
genre: backendGenre ?? genre,
|
label: backendLabel ?? label,
|
||||||
label: backendLabel ?? label,
|
copyright: backendCopyright,
|
||||||
copyright: backendCopyright,
|
writeExternalLrc: false,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
await _embedMetadataToOpus(
|
|
||||||
convertedPath,
|
|
||||||
trackToDownload,
|
|
||||||
genre: backendGenre ?? genre,
|
|
||||||
label: backendLabel ?? label,
|
|
||||||
copyright: backendCopyright,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final newExt = format == 'opus' ? '.opus' : '.mp3';
|
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||||
final newFileName = '${safBaseName ?? 'track'}$newExt';
|
|
||||||
final newUri = await _writeTempToSaf(
|
final newUri = await _writeTempToSaf(
|
||||||
treeUri: settings.downloadTreeUri,
|
treeUri: settings.downloadTreeUri,
|
||||||
relativeDir: effectiveOutputDir,
|
relativeDir: effectiveOutputDir,
|
||||||
fileName: newFileName,
|
fileName: newFileName,
|
||||||
mimeType: _mimeTypeForExt(newExt),
|
mimeType: _mimeTypeForExt('.flac'),
|
||||||
srcPath: convertedPath,
|
srcPath: flacPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newUri != null) {
|
if (newUri != null) {
|
||||||
@@ -3993,58 +4020,60 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
filePath = newUri;
|
filePath = newUri;
|
||||||
finalSafFileName = newFileName;
|
finalSafFileName = newFileName;
|
||||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
|
||||||
? '${tidalHighFormat.split('_').last}kbps'
|
|
||||||
: '320kbps';
|
|
||||||
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
|
||||||
} else {
|
} else {
|
||||||
_log.w(
|
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
||||||
'Failed to write converted $format to SAF, keeping M4A',
|
|
||||||
);
|
|
||||||
actualQuality = 'AAC 320kbps';
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.w(
|
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||||
'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 (_) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
} else {
|
_log.w('SAF M4A->FLAC conversion failed: $e');
|
||||||
_log.d('M4A file detected (SAF), converting to FLAC...');
|
} finally {
|
||||||
final tempPath = await _copySafToTemp(currentFilePath);
|
// Clean up temp files
|
||||||
if (tempPath != null) {
|
|
||||||
String? flacPath;
|
|
||||||
try {
|
try {
|
||||||
final length = await File(tempPath).length();
|
await File(tempPath).delete();
|
||||||
if (length < 1024) {
|
} catch (_) {}
|
||||||
_log.w('Temp M4A is too small (<1KB), skipping conversion');
|
if (flacPath != null) {
|
||||||
} else {
|
try {
|
||||||
updateItemStatus(
|
await File(flacPath).delete();
|
||||||
item.id,
|
} catch (_) {}
|
||||||
DownloadStatus.downloading,
|
}
|
||||||
progress: 0.95,
|
}
|
||||||
);
|
}
|
||||||
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
} else {
|
||||||
if (flacPath != null) {
|
_log.d(
|
||||||
_log.d('Converted to FLAC (temp): $flacPath');
|
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||||
_log.d(
|
);
|
||||||
'Embedding metadata and cover to converted 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(
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||||
trackToDownload,
|
trackToDownload,
|
||||||
result,
|
result,
|
||||||
@@ -4055,201 +4084,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final backendLabel = result['label'] as String?;
|
final backendLabel = result['label'] as String?;
|
||||||
final backendCopyright = result['copyright'] as String?;
|
final backendCopyright = result['copyright'] as String?;
|
||||||
|
|
||||||
|
if (backendGenre != null ||
|
||||||
|
backendLabel != null ||
|
||||||
|
backendCopyright != null) {
|
||||||
|
_log.d(
|
||||||
|
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await _embedMetadataAndCover(
|
await _embedMetadataAndCover(
|
||||||
flacPath,
|
flacPath,
|
||||||
finalTrack,
|
finalTrack,
|
||||||
genre: backendGenre ?? genre,
|
genre: backendGenre ?? genre,
|
||||||
label: backendLabel ?? label,
|
label: backendLabel ?? label,
|
||||||
copyright: backendCopyright,
|
copyright: backendCopyright,
|
||||||
writeExternalLrc: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
|
||||||
final newUri = await _writeTempToSaf(
|
|
||||||
treeUri: settings.downloadTreeUri,
|
|
||||||
relativeDir: effectiveOutputDir,
|
|
||||||
fileName: newFileName,
|
|
||||||
mimeType: _mimeTypeForExt('.flac'),
|
|
||||||
srcPath: flacPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newUri != null) {
|
|
||||||
if (newUri != currentFilePath) {
|
|
||||||
await _deleteSafFile(currentFilePath);
|
|
||||||
}
|
|
||||||
filePath = newUri;
|
|
||||||
finalSafFileName = newFileName;
|
|
||||||
} else {
|
|
||||||
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_log.w(
|
|
||||||
'FFmpeg conversion returned null, keeping M4A file',
|
|
||||||
);
|
);
|
||||||
|
_log.d('Metadata and cover embedded successfully');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} 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 {
|
} else {
|
||||||
await _embedMetadataToOpus(
|
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||||
convertedPath,
|
|
||||||
trackToDownload,
|
|
||||||
genre: backendGenre ?? genre,
|
|
||||||
label: backendLabel ?? label,
|
|
||||||
copyright: backendCopyright,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_log.d('Metadata embedded successfully');
|
|
||||||
} else {
|
|
||||||
_log.w('M4A to $format conversion failed, keeping M4A file');
|
|
||||||
actualQuality = 'AAC 320kbps';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_log.w('M4A conversion process failed: $e, keeping M4A file');
|
|
||||||
actualQuality = 'AAC 320kbps';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_log.d(
|
|
||||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final file = File(currentFilePath);
|
|
||||||
if (!await file.exists()) {
|
|
||||||
_log.e('File does not exist at path: $filePath');
|
|
||||||
} else {
|
|
||||||
final length = await file.length();
|
|
||||||
_log.i('File size before conversion: ${length / 1024} KB');
|
|
||||||
|
|
||||||
if (length < 1024) {
|
|
||||||
_log.w(
|
|
||||||
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
updateItemStatus(
|
|
||||||
item.id,
|
|
||||||
DownloadStatus.downloading,
|
|
||||||
progress: 0.95,
|
|
||||||
);
|
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(
|
|
||||||
currentFilePath,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (flacPath != null) {
|
|
||||||
filePath = flacPath;
|
|
||||||
_log.d('Converted to FLAC: $flacPath');
|
|
||||||
|
|
||||||
_log.d(
|
|
||||||
'Embedding metadata and cover to converted FLAC...',
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
||||||
trackToDownload,
|
|
||||||
result,
|
|
||||||
resolvedAlbumArtist,
|
|
||||||
);
|
|
||||||
|
|
||||||
final backendGenre = result['genre'] as String?;
|
|
||||||
final backendLabel = result['label'] as String?;
|
|
||||||
final backendCopyright = result['copyright'] as String?;
|
|
||||||
|
|
||||||
if (backendGenre != null ||
|
|
||||||
backendLabel != null ||
|
|
||||||
backendCopyright != null) {
|
|
||||||
_log.d(
|
|
||||||
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _embedMetadataAndCover(
|
|
||||||
flacPath,
|
|
||||||
finalTrack,
|
|
||||||
genre: backendGenre ?? genre,
|
|
||||||
label: backendLabel ?? label,
|
|
||||||
copyright: backendCopyright,
|
|
||||||
);
|
|
||||||
_log.d('Metadata and cover embedded successfully');
|
|
||||||
} catch (e) {
|
|
||||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_log.w(
|
|
||||||
'FFmpeg conversion returned null, keeping M4A file',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
_log.w(
|
|
||||||
'FFmpeg conversion process failed: $e, keeping M4A file',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (metadataEmbeddingEnabled &&
|
} else if (metadataEmbeddingEnabled &&
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
const _settingsKey = 'app_settings';
|
const _settingsKey = 'app_settings';
|
||||||
const _migrationVersionKey = 'settings_migration_version';
|
const _migrationVersionKey = 'settings_migration_version';
|
||||||
const _currentMigrationVersion = 5;
|
const _currentMigrationVersion = 6;
|
||||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||||
final _log = AppLogger('SettingsProvider');
|
final _log = AppLogger('SettingsProvider');
|
||||||
|
|
||||||
class SettingsNotifier extends Notifier<AppSettings> {
|
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 const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
|
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||||
await _normalizeYouTubeBitratesIfNeeded();
|
await _normalizeYouTubeBitratesIfNeeded();
|
||||||
await _normalizeSongLinkRegionIfNeeded();
|
await _normalizeSongLinkRegionIfNeeded();
|
||||||
}
|
}
|
||||||
@@ -114,6 +117,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
useCustomSpotifyCredentials: false,
|
useCustomSpotifyCredentials: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Migration 6: Tidal HIGH quality removed — migrate to LOSSLESS
|
||||||
|
if (state.audioQuality == 'HIGH') {
|
||||||
|
state = state.copyWith(audioQuality: 'LOSSLESS');
|
||||||
|
}
|
||||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
@@ -189,6 +196,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
await _saveSettings();
|
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) {
|
String _normalizeSongLinkRegion(String region) {
|
||||||
final normalized = region.trim().toUpperCase();
|
final normalized = region.trim().toUpperCase();
|
||||||
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
if (_isoRegionPattern.hasMatch(normalized)) return normalized;
|
||||||
@@ -430,11 +451,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setTidalHighFormat(String format) {
|
|
||||||
state = state.copyWith(tidalHighFormat: format);
|
|
||||||
_saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setYoutubeOpusBitrate(int bitrate) {
|
void setYoutubeOpusBitrate(int bitrate) {
|
||||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
||||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
state = state.copyWith(youtubeOpusBitrate: normalized);
|
||||||
@@ -502,6 +518,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLocalLibraryAutoScan(String mode) {
|
||||||
|
state = state.copyWith(localLibraryAutoScan: mode);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
void setTutorialComplete() {
|
void setTutorialComplete() {
|
||||||
state = state.copyWith(hasCompletedTutorial: true);
|
state = state.copyWith(hasCompletedTutorial: true);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Use extensionId if available, otherwise detect from albumId prefix
|
|
||||||
final providerId =
|
final providerId =
|
||||||
widget.extensionId ??
|
widget.extensionId ??
|
||||||
(() {
|
(() {
|
||||||
@@ -95,7 +94,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
.recordAlbumAccess(
|
.recordAlbumAccess(
|
||||||
id: widget.albumId,
|
id: widget.albumId,
|
||||||
name: widget.albumName,
|
name: widget.albumName,
|
||||||
artistName: widget.tracks?.firstOrNull?.artistName,
|
artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName,
|
||||||
imageUrl: widget.coverUrl,
|
imageUrl: widget.coverUrl,
|
||||||
providerId: providerId,
|
providerId: providerId,
|
||||||
);
|
);
|
||||||
@@ -134,9 +133,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
/// Upgrade cover URL to a higher resolution for full-screen display.
|
||||||
/// Spotify CDN only has 300, 640, ~2000 — we stay at 640 (no intermediate).
|
|
||||||
/// Deezer CDN: upgrade to 1000x1000 (available: 56, 250, 500, 1000, 1400, 1800).
|
|
||||||
String? _highResCoverUrl(String? url) {
|
String? _highResCoverUrl(String? url) {
|
||||||
if (url == null) return null;
|
if (url == null) return null;
|
||||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||||
@@ -283,7 +280,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
) {
|
) {
|
||||||
final expandedHeight = _calculateExpandedHeight(context);
|
final expandedHeight = _calculateExpandedHeight(context);
|
||||||
final tracks = _tracks ?? [];
|
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;
|
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
@@ -516,7 +516,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
// Info is now displayed in the full-screen cover overlay
|
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,37 +570,74 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
void _downloadAll(BuildContext context) {
|
void _downloadAll(BuildContext context) {
|
||||||
final tracks = _tracks;
|
final tracks = _tracks;
|
||||||
if (tracks == null || tracks.isEmpty) return;
|
if (tracks == null || tracks.isEmpty) return;
|
||||||
|
|
||||||
|
// Skip already-downloaded tracks
|
||||||
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||||
|
? ref.read(localLibraryProvider)
|
||||||
|
: null;
|
||||||
|
final tracksToQueue = <Track>[];
|
||||||
|
int skippedCount = 0;
|
||||||
|
|
||||||
|
for (final track in tracks) {
|
||||||
|
final isInHistory = historyState.isDownloaded(track.id) ||
|
||||||
|
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||||
|
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||||
|
final isInLocal = localLibState?.existsInLibrary(
|
||||||
|
isrc: track.isrc,
|
||||||
|
trackName: track.name,
|
||||||
|
artistName: track.artistName,
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
if (isInHistory || isInLocal) {
|
||||||
|
skippedCount++;
|
||||||
|
} else {
|
||||||
|
tracksToQueue.add(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracksToQueue.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
trackName: '${tracks.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: widget.albumName,
|
artistName: widget.albumName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
.addMultipleToQueue(tracksToQueue, service, qualityOverride: quality);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, settings.defaultService);
|
.addMultipleToQueue(tracksToQueue, settings.defaultService);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||||
|
final message = skipped > 0
|
||||||
|
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||||
|
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildLoveAllButton() {
|
Widget _buildLoveAllButton() {
|
||||||
final collectionsState = ref.watch(libraryCollectionsProvider);
|
final collectionsState = ref.watch(libraryCollectionsProvider);
|
||||||
final tracks = _tracks;
|
final tracks = _tracks;
|
||||||
|
|||||||
@@ -910,8 +910,44 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<DownloadHistoryItem> allTracks,
|
List<DownloadHistoryItem> allTracks,
|
||||||
) {
|
) {
|
||||||
String selectedFormat = 'MP3';
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
String selectedBitrate = '320k';
|
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -923,7 +959,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setSheetState) {
|
builder: (context, setSheetState) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final formats = ['MP3', 'Opus'];
|
|
||||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -960,51 +995,75 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: bitrates.map((br) {
|
children: formats.map((format) {
|
||||||
final isSelected = br == selectedBitrate;
|
final isSelected = format == selectedFormat;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(br),
|
label: Text(format),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setSheetState(() => selectedBitrate = br);
|
setSheetState(() {
|
||||||
|
selectedFormat = format;
|
||||||
|
isLosslessTarget =
|
||||||
|
format == 'ALAC' || format == 'FLAC';
|
||||||
|
if (!isLosslessTarget) {
|
||||||
|
selectedBitrate =
|
||||||
|
format == 'Opus' ? '128k' : '320k';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).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),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -1057,12 +1116,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
: item.filePath.toLowerCase();
|
: item.filePath.toLowerCase();
|
||||||
final ext = nameToCheck.endsWith('.flac')
|
final ext = nameToCheck.endsWith('.flac')
|
||||||
? 'FLAC'
|
? 'FLAC'
|
||||||
|
: nameToCheck.endsWith('.m4a')
|
||||||
|
? 'M4A'
|
||||||
: nameToCheck.endsWith('.mp3')
|
: nameToCheck.endsWith('.mp3')
|
||||||
? 'MP3'
|
? 'MP3'
|
||||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||||
? 'Opus'
|
? 'Opus'
|
||||||
: null;
|
: 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) {
|
if (selected.isEmpty) {
|
||||||
@@ -1074,16 +1140,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
context.l10n.selectionBatchConvertConfirmMessage(
|
isLossless
|
||||||
selected.length,
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
targetFormat,
|
selected.length,
|
||||||
bitrate,
|
targetFormat,
|
||||||
),
|
)
|
||||||
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
selected.length,
|
||||||
|
targetFormat,
|
||||||
|
bitrate,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -1103,8 +1175,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
final total = selected.length;
|
final total = selected.length;
|
||||||
final historyDb = HistoryDatabase.instance;
|
final historyDb = HistoryDatabase.instance;
|
||||||
final newQuality =
|
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
|
||||||
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
targetFormat.toUpperCase() == 'FLAC')
|
||||||
|
? '${targetFormat.toUpperCase()} Lossless'
|
||||||
|
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
@@ -1207,13 +1281,27 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final baseName = dotIdx > 0
|
final baseName = dotIdx > 0
|
||||||
? oldFileName.substring(0, dotIdx)
|
? oldFileName.substring(0, dotIdx)
|
||||||
: oldFileName;
|
: oldFileName;
|
||||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
String newExt;
|
||||||
? '.opus'
|
String mimeType;
|
||||||
: '.mp3';
|
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 newFileName = '$baseName$newExt';
|
||||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
|
||||||
? 'audio/opus'
|
|
||||||
: 'audio/mpeg';
|
|
||||||
|
|
||||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
treeUri: treeUri,
|
treeUri: treeUri,
|
||||||
|
|||||||
@@ -3909,6 +3909,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
|||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
albumName: (data['album_name'] ?? widget.albumName).toString(),
|
||||||
|
albumArtist: (data['album_artist'] ?? _artistName)?.toString(),
|
||||||
artistId:
|
artistId:
|
||||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedKeys = {};
|
final Set<String> _selectedKeys = {};
|
||||||
|
UserPlaylistCollection? playlist;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -243,7 +244,6 @@ class _LibraryTracksFolderScreenState
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
ref.watch(localLibraryProvider.select((s) => s.items));
|
ref.watch(localLibraryProvider.select((s) => s.items));
|
||||||
final localState = ref.read(localLibraryProvider);
|
final localState = ref.read(localLibraryProvider);
|
||||||
final UserPlaylistCollection? playlist;
|
|
||||||
final List<CollectionTrackEntry> entries;
|
final List<CollectionTrackEntry> entries;
|
||||||
|
|
||||||
switch (widget.mode) {
|
switch (widget.mode) {
|
||||||
@@ -873,6 +873,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
void _downloadAll(List<Track> tracks) {
|
void _downloadAll(List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
final playlistName = widget.mode == LibraryTracksFolderMode.playlist ? playlist?.name ?? context.l10n.collectionPlaylist : null;
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
@@ -885,7 +886,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, service, qualityOverride: quality);
|
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: playlistName);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -899,7 +900,7 @@ class _LibraryTracksFolderScreenState
|
|||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, settings.defaultService);
|
.addMultipleToQueue(tracks, settings.defaultService, playlistName: playlistName);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
import 'package:spotiflac_android/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/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/utils/file_access.dart';
|
import 'package:spotiflac_android/utils/file_access.dart';
|
||||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.dart';
|
import 'package:spotiflac_android/services/library_database.dart';
|
||||||
import 'package:spotiflac_android/services/ffmpeg_service.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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
@@ -41,11 +45,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
void _showCueVirtualTrackSnackBar() {
|
void _showCueVirtualTrackSnackBar() {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(content: Text(cueVirtualTrackRequiresSplitMessage)),
|
||||||
content: Text(cueVirtualTrackRequiresSplitMessage),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
late List<int> _sortedDiscNumbersCache;
|
late List<int> _sortedDiscNumbersCache;
|
||||||
late bool _hasMultipleDiscsCache;
|
late bool _hasMultipleDiscsCache;
|
||||||
String? _commonQualityCache;
|
String? _commonQualityCache;
|
||||||
@@ -897,6 +900,127 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return false;
|
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 {
|
Future<void> _reEnrichSelected(List<LocalLibraryItem> allTracks) async {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final selected = <LocalLibraryItem>[];
|
final selected = <LocalLibraryItem>[];
|
||||||
@@ -1005,8 +1129,56 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<LocalLibraryItem> allTracks,
|
List<LocalLibraryItem> allTracks,
|
||||||
) {
|
) {
|
||||||
String selectedFormat = 'MP3';
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
String selectedBitrate = '320k';
|
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1018,7 +1190,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setSheetState) {
|
builder: (context, setSheetState) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final formats = ['MP3', 'Opus'];
|
|
||||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -1055,51 +1226,75 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: bitrates.map((br) {
|
children: formats.map((format) {
|
||||||
final isSelected = br == selectedBitrate;
|
final isSelected = format == selectedFormat;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(br),
|
label: Text(format),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setSheetState(() => selectedBitrate = br);
|
setSheetState(() {
|
||||||
|
selectedFormat = format;
|
||||||
|
isLosslessTarget =
|
||||||
|
format == 'ALAC' || format == 'FLAC';
|
||||||
|
if (!isLosslessTarget) {
|
||||||
|
selectedBitrate =
|
||||||
|
format == 'Opus' ? '128k' : '320k';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).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),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -1152,6 +1347,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final fmt = item.format!.toLowerCase();
|
final fmt = item.format!.toLowerCase();
|
||||||
if (fmt == 'flac') {
|
if (fmt == 'flac') {
|
||||||
currentFormat = 'FLAC';
|
currentFormat = 'FLAC';
|
||||||
|
} else if (fmt == 'm4a') {
|
||||||
|
currentFormat = 'M4A';
|
||||||
} else if (fmt == 'mp3') {
|
} else if (fmt == 'mp3') {
|
||||||
currentFormat = 'MP3';
|
currentFormat = 'MP3';
|
||||||
} else if (fmt == 'opus' || fmt == 'ogg') {
|
} else if (fmt == 'opus' || fmt == 'ogg') {
|
||||||
@@ -1163,15 +1360,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final lower = item.filePath.toLowerCase();
|
final lower = item.filePath.toLowerCase();
|
||||||
if (lower.endsWith('.flac')) {
|
if (lower.endsWith('.flac')) {
|
||||||
currentFormat = 'FLAC';
|
currentFormat = 'FLAC';
|
||||||
|
} else if (lower.endsWith('.m4a')) {
|
||||||
|
currentFormat = 'M4A';
|
||||||
} else if (lower.endsWith('.mp3')) {
|
} else if (lower.endsWith('.mp3')) {
|
||||||
currentFormat = 'MP3';
|
currentFormat = 'MP3';
|
||||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||||
currentFormat = 'Opus';
|
currentFormat = 'Opus';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentFormat != null && currentFormat != targetFormat) {
|
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||||
selected.add(item);
|
// 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) {
|
if (selected.isEmpty) {
|
||||||
@@ -1183,16 +1385,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
context.l10n.selectionBatchConvertConfirmMessage(
|
isLossless
|
||||||
selected.length,
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
targetFormat,
|
selected.length,
|
||||||
bitrate,
|
targetFormat,
|
||||||
),
|
)
|
||||||
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
selected.length,
|
||||||
|
targetFormat,
|
||||||
|
bitrate,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -1357,13 +1565,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
final baseName = dotIdx > 0
|
final baseName = dotIdx > 0
|
||||||
? oldFileName.substring(0, dotIdx)
|
? oldFileName.substring(0, dotIdx)
|
||||||
: oldFileName;
|
: oldFileName;
|
||||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
String newExt;
|
||||||
? '.opus'
|
String mimeType;
|
||||||
: '.mp3';
|
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 newFileName = '$baseName$newExt';
|
||||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
|
||||||
? 'audio/opus'
|
|
||||||
: 'audio/mpeg';
|
|
||||||
|
|
||||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
treeUri: treeUri,
|
treeUri: treeUri,
|
||||||
@@ -1525,6 +1747,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: _LocalAlbumSelectionActionButton(
|
child: _LocalAlbumSelectionActionButton(
|
||||||
icon: Icons.auto_fix_high_outlined,
|
icon: Icons.auto_fix_high_outlined,
|
||||||
|
|||||||
@@ -350,7 +350,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
// Info is now displayed in the full-screen cover overlay
|
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +532,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||||
onPressed: _tracks.isEmpty
|
onPressed: _tracks.isEmpty
|
||||||
? null
|
? null
|
||||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks),
|
: () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,45 +607,82 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
|
|
||||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
|
// Skip already-downloaded tracks
|
||||||
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||||
|
? ref.read(localLibraryProvider)
|
||||||
|
: null;
|
||||||
|
final tracksToQueue = <Track>[];
|
||||||
|
int skippedCount = 0;
|
||||||
|
|
||||||
|
for (final track in tracks) {
|
||||||
|
final isInHistory = historyState.isDownloaded(track.id) ||
|
||||||
|
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||||
|
historyState.findByTrackAndArtist(track.name, track.artistName) != null;
|
||||||
|
final isInLocal = localLibState?.existsInLibrary(
|
||||||
|
isrc: track.isrc,
|
||||||
|
trackName: track.name,
|
||||||
|
artistName: track.artistName,
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
if (isInHistory || isInLocal) {
|
||||||
|
skippedCount++;
|
||||||
|
} else {
|
||||||
|
tracksToQueue.add(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracksToQueue.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
trackName: '${tracks.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: _playlistName,
|
artistName: _playlistName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(
|
.addMultipleToQueue(
|
||||||
tracks,
|
tracksToQueue,
|
||||||
service,
|
service,
|
||||||
qualityOverride: quality,
|
qualityOverride: quality,
|
||||||
playlistName: _playlistName,
|
playlistName: _playlistName,
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(
|
.addMultipleToQueue(
|
||||||
tracks,
|
tracksToQueue,
|
||||||
settings.defaultService,
|
settings.defaultService,
|
||||||
playlistName: _playlistName,
|
playlistName: _playlistName,
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||||
SnackBar(
|
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showQueuedSnackbar(BuildContext context, int added, int skipped) {
|
||||||
|
final message = skipped > 0
|
||||||
|
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||||
|
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||||
|
|||||||
+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/download_item.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||||
import 'package:spotiflac_android/services/library_database.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/history_database.dart';
|
||||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.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/clickable_metadata.dart';
|
||||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||||
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
|
||||||
enum LibraryItemSource { downloaded, local }
|
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 {
|
Future<void> _deleteSelectedPlaylists(BuildContext context) async {
|
||||||
final count = _selectedPlaylistIds.length;
|
final count = _selectedPlaylistIds.length;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@@ -1452,6 +1543,37 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
const SizedBox(height: 12),
|
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(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
@@ -4362,6 +4484,127 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
return false;
|
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(
|
Future<void> _reEnrichSelectedLocalFromQueue(
|
||||||
List<UnifiedLibraryItem> allItems,
|
List<UnifiedLibraryItem> allItems,
|
||||||
) async {
|
) async {
|
||||||
@@ -4512,8 +4755,50 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<UnifiedLibraryItem> allItems,
|
List<UnifiedLibraryItem> allItems,
|
||||||
) async {
|
) async {
|
||||||
String selectedFormat = 'MP3';
|
final itemsById = {for (final item in allItems) item.id: item};
|
||||||
String selectedBitrate = '320k';
|
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;
|
var didStartConversion = false;
|
||||||
|
|
||||||
_hideSelectionOverlay();
|
_hideSelectionOverlay();
|
||||||
@@ -4529,7 +4814,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setSheetState) {
|
builder: (context, setSheetState) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final formats = ['MP3', 'Opus'];
|
|
||||||
final bitrates = ['128k', '192k', '256k', '320k'];
|
final bitrates = ['128k', '192k', '256k', '320k'];
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -4566,51 +4850,75 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: bitrates.map((br) {
|
children: formats.map((format) {
|
||||||
final isSelected = br == selectedBitrate;
|
final isSelected = format == selectedFormat;
|
||||||
return ChoiceChip(
|
return ChoiceChip(
|
||||||
label: Text(br),
|
label: Text(format),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
onSelected: (selected) {
|
onSelected: (selected) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setSheetState(() => selectedBitrate = br);
|
setSheetState(() {
|
||||||
|
selectedFormat = format;
|
||||||
|
isLosslessTarget =
|
||||||
|
format == 'ALAC' || format == 'FLAC';
|
||||||
|
if (!isLosslessTarget) {
|
||||||
|
selectedBitrate =
|
||||||
|
format == 'Opus' ? '128k' : '320k';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).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),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -4686,14 +4994,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
final ext = nameToCheck.endsWith('.flac')
|
final ext = nameToCheck.endsWith('.flac')
|
||||||
? 'FLAC'
|
? 'FLAC'
|
||||||
|
: nameToCheck.endsWith('.m4a')
|
||||||
|
? 'M4A'
|
||||||
: nameToCheck.endsWith('.mp3')
|
: nameToCheck.endsWith('.mp3')
|
||||||
? 'MP3'
|
? 'MP3'
|
||||||
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
: (nameToCheck.endsWith('.opus') || nameToCheck.endsWith('.ogg'))
|
||||||
? 'Opus'
|
? 'Opus'
|
||||||
: null;
|
: null;
|
||||||
if (ext != null && ext != targetFormat) {
|
if (ext == null || ext == targetFormat) continue;
|
||||||
selectedItems.add(item);
|
// 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) {
|
if (selectedItems.isEmpty) {
|
||||||
@@ -4706,16 +5019,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Confirm
|
// Confirm
|
||||||
|
final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
title: Text(context.l10n.selectionBatchConvertConfirmTitle),
|
||||||
content: Text(
|
content: Text(
|
||||||
context.l10n.selectionBatchConvertConfirmMessage(
|
isLossless
|
||||||
selectedItems.length,
|
? context.l10n.selectionBatchConvertConfirmMessageLossless(
|
||||||
targetFormat,
|
selectedItems.length,
|
||||||
bitrate,
|
targetFormat,
|
||||||
),
|
)
|
||||||
|
: context.l10n.selectionBatchConvertConfirmMessage(
|
||||||
|
selectedItems.length,
|
||||||
|
targetFormat,
|
||||||
|
bitrate,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -4735,8 +5054,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
final total = selectedItems.length;
|
final total = selectedItems.length;
|
||||||
final historyDb = HistoryDatabase.instance;
|
final historyDb = HistoryDatabase.instance;
|
||||||
final newQuality =
|
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
|
||||||
'${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
targetFormat.toUpperCase() == 'FLAC')
|
||||||
|
? '${targetFormat.toUpperCase()} Lossless'
|
||||||
|
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final shouldEmbedLyrics =
|
final shouldEmbedLyrics =
|
||||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||||
@@ -4850,13 +5171,27 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final baseName = dotIdx > 0
|
final baseName = dotIdx > 0
|
||||||
? oldFileName.substring(0, dotIdx)
|
? oldFileName.substring(0, dotIdx)
|
||||||
: oldFileName;
|
: oldFileName;
|
||||||
final newExt = targetFormat.toLowerCase() == 'opus'
|
String newExt;
|
||||||
? '.opus'
|
String mimeType;
|
||||||
: '.mp3';
|
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 newFileName = '$baseName$newExt';
|
||||||
final mimeType = targetFormat.toLowerCase() == 'opus'
|
|
||||||
? 'audio/opus'
|
|
||||||
: 'audio/mpeg';
|
|
||||||
|
|
||||||
final safUri = await PlatformBridge.createSafFileFromPath(
|
final safUri = await PlatformBridge.createSafFileFromPath(
|
||||||
treeUri: treeUri,
|
treeUri: treeUri,
|
||||||
@@ -5129,6 +5464,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// Action buttons row: Share/Re-enrich, Convert, Delete
|
// Action buttons row: Share/Re-enrich, Convert, Delete
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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(
|
Expanded(
|
||||||
child: _SelectionActionButton(
|
child: _SelectionActionButton(
|
||||||
icon: localOnlySelection
|
icon: localOnlySelection
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
const donorNames = <String>['a fan'];
|
const donorNames = <String>['micahRichie', 'a fan', 'mc nuggets jimmy', 'CJBGR'];
|
||||||
|
|
||||||
// Match SettingsGroup color logic
|
// Match SettingsGroup color logic
|
||||||
final cardColor = isDark
|
final cardColor = isDark
|
||||||
@@ -479,8 +479,8 @@ int _cr(String v) {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlighted supporters (hashes of names): none for now.
|
// Highlighted supporters (hashes of names).
|
||||||
const _cv = <int>{};
|
const _cv = <int>{1211573191};
|
||||||
|
|
||||||
class _SupporterChip extends StatelessWidget {
|
class _SupporterChip extends StatelessWidget {
|
||||||
final String name;
|
final String name;
|
||||||
|
|||||||
@@ -300,7 +300,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
final topPadding = normalizedHeaderTopPadding(context);
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||||
final isTidalService = settings.defaultService == 'tidal';
|
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true,
|
canPop: true,
|
||||||
@@ -408,35 +407,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
.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) ...[
|
if (!isBuiltInService) ...[
|
||||||
Padding(
|
Padding(
|
||||||
@@ -464,12 +436,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
],
|
],
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
title: context.l10n.youtubeOpusBitrateTitle,
|
title: context.l10n.youtubeOpusBitrateTitle,
|
||||||
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256)',
|
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)',
|
||||||
onTap: () => _showYoutubeBitratePicker(
|
onTap: () => _showYoutubeBitratePicker(
|
||||||
context: context,
|
context: context,
|
||||||
title: context.l10n.youtubeOpusBitrateTitle,
|
title: context.l10n.youtubeOpusBitrateTitle,
|
||||||
currentValue: settings.youtubeOpusBitrate,
|
currentValue: settings.youtubeOpusBitrate,
|
||||||
options: const [128, 256],
|
options: const [128, 256, 320],
|
||||||
onSave: (value) => ref
|
onSave: (value) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setYoutubeOpusBitrate(value),
|
.setYoutubeOpusBitrate(value),
|
||||||
@@ -1338,8 +1310,27 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
|
||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
String? result;
|
||||||
|
try {
|
||||||
|
result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
} catch (e) {
|
||||||
|
if (ctx.mounted) {
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to open folder picker: $e'),
|
||||||
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
// iOS: Validate the selected path is writable (not iCloud or container root)
|
// iOS: Validate the selected path is writable (not iCloud or container root)
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
@@ -1691,104 +1682,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(
|
void _showNetworkModePicker(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
|
|||||||
@@ -241,6 +241,99 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getAutoScanLabel(BuildContext context, String mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case 'on_open':
|
||||||
|
return context.l10n.libraryAutoScanOnOpen;
|
||||||
|
case 'daily':
|
||||||
|
return context.l10n.libraryAutoScanDaily;
|
||||||
|
case 'weekly':
|
||||||
|
return context.l10n.libraryAutoScanWeekly;
|
||||||
|
default:
|
||||||
|
return context.l10n.libraryAutoScanOff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAutoScanPicker(BuildContext context, 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.libraryAutoScan,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleLarge
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.libraryAutoScanSubtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_AutoScanOption(
|
||||||
|
icon: Icons.block,
|
||||||
|
title: context.l10n.libraryAutoScanOff,
|
||||||
|
selected: current == 'off',
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AutoScanOption(
|
||||||
|
icon: Icons.open_in_new,
|
||||||
|
title: context.l10n.libraryAutoScanOnOpen,
|
||||||
|
selected: current == 'on_open',
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AutoScanOption(
|
||||||
|
icon: Icons.today,
|
||||||
|
title: context.l10n.libraryAutoScanDaily,
|
||||||
|
selected: current == 'daily',
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_AutoScanOption(
|
||||||
|
icon: Icons.date_range,
|
||||||
|
title: context.l10n.libraryAutoScanWeekly,
|
||||||
|
selected: current == 'weekly',
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
@@ -344,7 +437,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setLocalLibraryShowDuplicates(value),
|
.setLocalLibraryShowDuplicates(value),
|
||||||
showDivider: false,
|
),
|
||||||
|
Opacity(
|
||||||
|
opacity: settings.localLibraryEnabled ? 1.0 : 0.5,
|
||||||
|
child: SettingsItem(
|
||||||
|
icon: Icons.autorenew_rounded,
|
||||||
|
title: context.l10n.libraryAutoScan,
|
||||||
|
subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan),
|
||||||
|
onTap: settings.localLibraryEnabled
|
||||||
|
? () => _showAutoScanPicker(context, settings.localLibraryAutoScan)
|
||||||
|
: null,
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -825,3 +929,31 @@ class _ScanProgressTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AutoScanOption extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final bool selected;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _AutoScanOption({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.selected,
|
||||||
|
required this.colorScheme,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon),
|
||||||
|
title: Text(title),
|
||||||
|
trailing: selected
|
||||||
|
? Icon(Icons.check, color: colorScheme.primary)
|
||||||
|
: null,
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -321,7 +321,26 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
title: Text(context.l10n.setupChooseFromFiles),
|
title: Text(context.l10n.setupChooseFromFiles),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
if (Platform.isIOS) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
String? result;
|
||||||
|
try {
|
||||||
|
result = await FilePicker.platform.getDirectoryPath();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to open folder picker: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
// iOS: Validate the selected path is writable
|
// iOS: Validate the selected path is writable
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
|
|||||||
+13
-16
@@ -269,22 +269,19 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: SettingsGroup(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
children: filteredExtensions.asMap().entries.map((entry) {
|
||||||
child: SettingsGroup(
|
final index = entry.key;
|
||||||
children: filteredExtensions.asMap().entries.map((entry) {
|
final ext = entry.value;
|
||||||
final index = entry.key;
|
return _ExtensionItem(
|
||||||
final ext = entry.value;
|
extension: ext,
|
||||||
return _ExtensionItem(
|
showDivider: index < filteredExtensions.length - 1,
|
||||||
extension: ext,
|
isDownloading: downloadingId == ext.id,
|
||||||
showDivider: index < filteredExtensions.length - 1,
|
onInstall: () => _installExtension(ext),
|
||||||
isDownloading: downloadingId == ext.id,
|
onUpdate: () => _updateExtension(ext),
|
||||||
onInstall: () => _installExtension(ext),
|
onTap: () => _showExtensionDetails(ext),
|
||||||
onUpdate: () => _updateExtension(ext),
|
);
|
||||||
onTap: () => _showExtensionDetails(ext),
|
}).toList(),
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
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.
|
/// 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.
|
/// Returns the new file path on success, null on failure.
|
||||||
static Future<String?> convertAudioFormat({
|
static Future<String?> convertAudioFormat({
|
||||||
required String inputPath,
|
required String inputPath,
|
||||||
@@ -1220,11 +1221,30 @@ class FFmpegService {
|
|||||||
bool deleteOriginal = true,
|
bool deleteOriginal = true,
|
||||||
}) async {
|
}) async {
|
||||||
final format = targetFormat.toLowerCase();
|
final format = targetFormat.toLowerCase();
|
||||||
if (format != 'mp3' && format != 'opus') {
|
if (!const {'mp3', 'opus', 'alac', 'flac'}.contains(format)) {
|
||||||
_log.e('Unsupported target format: $targetFormat');
|
_log.e('Unsupported target format: $targetFormat');
|
||||||
return null;
|
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 extension = format == 'opus' ? '.opus' : '.mp3';
|
||||||
final outputPath = _buildOutputPath(inputPath, extension);
|
final outputPath = _buildOutputPath(inputPath, extension);
|
||||||
|
|
||||||
@@ -1296,6 +1316,257 @@ class FFmpegService {
|
|||||||
return outputPath;
|
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(
|
static Map<String, String> _convertToId3Tags(
|
||||||
Map<String, String> vorbisMetadata,
|
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(?:/(.*))?$',
|
r'^Data/Application/[A-F0-9\-]+/Documents(?:/(.*))?$',
|
||||||
caseSensitive: false,
|
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.
|
/// Checks if a path is a valid writable directory on iOS.
|
||||||
/// Returns false if:
|
/// Returns false if:
|
||||||
@@ -43,6 +59,12 @@ bool isValidIosWritablePath(String path) {
|
|||||||
return false;
|
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.)
|
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
|
||||||
// This handles cases where FilePicker returns container root
|
// This handles cases where FilePicker returns container root
|
||||||
final containerPattern = RegExp(
|
final containerPattern = RegExp(
|
||||||
@@ -70,11 +92,19 @@ Future<String> validateOrFixIosPath(
|
|||||||
if (!Platform.isIOS) return path;
|
if (!Platform.isIOS) return path;
|
||||||
|
|
||||||
final trimmed = path.trim();
|
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)) {
|
if (isValidIosWritablePath(trimmed)) {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
final docDir = await getApplicationDocumentsDirectory();
|
|
||||||
final candidates = <String>[];
|
final candidates = <String>[];
|
||||||
|
|
||||||
if (trimmed.isNotEmpty) {
|
if (trimmed.isNotEmpty) {
|
||||||
@@ -92,14 +122,8 @@ Future<String> validateOrFixIosPath(
|
|||||||
trimmed,
|
trimmed,
|
||||||
);
|
);
|
||||||
if (legacyRelativeMatch != null) {
|
if (legacyRelativeMatch != null) {
|
||||||
final suffix = (legacyRelativeMatch.group(1) ?? '').trim();
|
|
||||||
final normalizedSuffix = suffix.startsWith('/')
|
|
||||||
? suffix.substring(1)
|
|
||||||
: suffix;
|
|
||||||
candidates.add(
|
candidates.add(
|
||||||
normalizedSuffix.isEmpty
|
_joinRecoveredIosPath(docDir.path, legacyRelativeMatch.group(1) ?? ''),
|
||||||
? docDir.path
|
|
||||||
: '${docDir.path}/$normalizedSuffix',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +133,7 @@ Future<String> validateOrFixIosPath(
|
|||||||
final index = trimmed.indexOf(documentsMarker);
|
final index = trimmed.indexOf(documentsMarker);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
final suffix = trimmed.substring(index + documentsMarker.length).trim();
|
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
|
// Check for container root without subdirectory
|
||||||
final containerPattern = RegExp(
|
final containerPattern = RegExp(
|
||||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
|
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
|
||||||
|
|||||||
@@ -8,6 +8,33 @@ const _androidStoragePathAliases = <String>[
|
|||||||
'/mnt/sdcard',
|
'/mnt/sdcard',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Audio file extensions that the app commonly produces or converts between.
|
||||||
|
/// Used to generate extension-stripped match keys so that a file converted from
|
||||||
|
/// one format to another (e.g. .flac → .opus) is still recognised as the same
|
||||||
|
/// track.
|
||||||
|
const _audioExtensions = <String>[
|
||||||
|
'.flac',
|
||||||
|
'.m4a',
|
||||||
|
'.mp3',
|
||||||
|
'.opus',
|
||||||
|
'.ogg',
|
||||||
|
'.wav',
|
||||||
|
'.aac',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Strips a trailing audio extension from [path] if present.
|
||||||
|
/// Returns the path without extension, or `null` if no known audio extension
|
||||||
|
/// was found.
|
||||||
|
String? _stripAudioExtension(String path) {
|
||||||
|
final lower = path.toLowerCase();
|
||||||
|
for (final ext in _audioExtensions) {
|
||||||
|
if (lower.endsWith(ext)) {
|
||||||
|
return path.substring(0, path.length - ext.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> buildPathMatchKeys(String? filePath) {
|
Set<String> buildPathMatchKeys(String? filePath) {
|
||||||
final raw = filePath?.trim() ?? '';
|
final raw = filePath?.trim() ?? '';
|
||||||
if (raw.isEmpty) return const {};
|
if (raw.isEmpty) return const {};
|
||||||
@@ -79,6 +106,18 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addNormalized(cleaned);
|
addNormalized(cleaned);
|
||||||
|
|
||||||
|
// Add extension-stripped variants so that a file converted from one audio
|
||||||
|
// format to another (e.g. Song.flac → Song.opus) still matches.
|
||||||
|
final extensionStrippedKeys = <String>{};
|
||||||
|
for (final key in keys) {
|
||||||
|
final stripped = _stripAudioExtension(key);
|
||||||
|
if (stripped != null && stripped.isNotEmpty) {
|
||||||
|
extensionStrippedKeys.add(stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.addAll(extensionStrippedKeys);
|
||||||
|
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class BuiltInService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Default quality options for built-in services
|
/// 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 = [
|
const _builtInServices = [
|
||||||
BuiltInService(
|
BuiltInService(
|
||||||
id: 'tidal',
|
id: 'tidal',
|
||||||
@@ -83,9 +83,9 @@ const _builtInServices = [
|
|||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
qualityOptions: [
|
qualityOptions: [
|
||||||
QualityOption(
|
QualityOption(
|
||||||
id: 'opus_256',
|
id: 'opus_320',
|
||||||
label: 'Opus 256kbps',
|
label: 'Opus 320kbps',
|
||||||
description: 'Best quality lossy (~8MB per track)',
|
description: 'Best quality lossy (~10MB per track)',
|
||||||
),
|
),
|
||||||
QualityOption(
|
QualityOption(
|
||||||
id: 'mp3_320',
|
id: 'mp3_320',
|
||||||
@@ -146,7 +146,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
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];
|
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||||
|
|
||||||
late String _selectedService;
|
late String _selectedService;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Future<void> showAddTracksToPlaylistSheet(
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
List<Track> tracks,
|
List<Track> tracks,
|
||||||
|
{String? playlistNamePrefill}
|
||||||
) async {
|
) async {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
@@ -31,15 +32,16 @@ Future<void> showAddTracksToPlaylistSheet(
|
|||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
return _PlaylistPickerSheetContent(tracks: tracks);
|
return _PlaylistPickerSheetContent(tracks: tracks, playlistNamePrefill: playlistNamePrefill);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
|
class _PlaylistPickerSheetContent extends ConsumerStatefulWidget {
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
|
final String? playlistNamePrefill;
|
||||||
|
|
||||||
const _PlaylistPickerSheetContent({required this.tracks});
|
const _PlaylistPickerSheetContent({required this.tracks, this.playlistNamePrefill});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_PlaylistPickerSheetContent> createState() =>
|
ConsumerState<_PlaylistPickerSheetContent> createState() =>
|
||||||
@@ -130,7 +132,7 @@ class _PlaylistPickerSheetContentState
|
|||||||
leading: const Icon(Icons.add_circle_outline),
|
leading: const Icon(Icons.add_circle_outline),
|
||||||
title: Text(context.l10n.collectionCreatePlaylist),
|
title: Text(context.l10n.collectionCreatePlaylist),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final name = await _promptPlaylistName(context);
|
final name = await _promptPlaylistName(context, widget.playlistNamePrefill);
|
||||||
if (name == null || name.trim().isEmpty || !context.mounted) {
|
if (name == null || name.trim().isEmpty || !context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -221,8 +223,8 @@ class _PlaylistPickerSheetContentState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _promptPlaylistName(BuildContext context) async {
|
Future<String?> _promptPlaylistName(BuildContext context, String? playlistNamePrefill) async {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController(text: playlistNamePrefill);
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
final result = await showDialog<String>(
|
final result = await showDialog<String>(
|
||||||
|
|||||||
+12
-20
@@ -133,10 +133,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -557,14 +557,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.5"
|
version: "1.0.5"
|
||||||
js:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: js
|
|
||||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.7.2"
|
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -633,18 +625,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1166,26 +1158,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.26.3"
|
version: "1.30.0"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.10"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.12"
|
version: "0.6.16"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.8.0+106
|
version: 3.8.6+112
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user