mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 637504db41 | |||
| 48e499eaeb | |||
| 7372a34d25 | |||
| 4411d80a19 | |||
| 316d7677c7 | |||
| fa061fc587 | |||
| 38605080b7 | |||
| 478179169c | |||
| 83594831a9 | |||
| cec3acfff6 | |||
| 18ef5e0aee | |||
| f674eef681 | |||
| 1b95085977 | |||
| 35ab00a7bd | |||
| f2ec276b91 | |||
| ee797756f7 | |||
| 2d54ac1d12 | |||
| 87f624c685 | |||
| 48ec563aa1 | |||
| 070e0cd8cf | |||
| 948d7aa735 | |||
| 1aaa033dc1 | |||
| 56a7ec0763 | |||
| 7da5f69551 | |||
| ace70de9e1 | |||
| e7369bb4a9 | |||
| cd6598a866 | |||
| 93dc95ccc4 | |||
| 951518ba81 | |||
| e3449ded60 | |||
| 913db0c97d | |||
| f675c1f223 | |||
| 2d8ee8b04f | |||
| ef1f1b381f | |||
| e2dce6c623 | |||
| 1da8228f89 | |||
| 67df645ca0 | |||
| 258166c973 | |||
| 780aa8494b | |||
| 0a539bde70 | |||
| 5232af5a36 | |||
| 01b4c257ff | |||
| 914c179a1c | |||
| 6d3bea874c | |||
| 10a3fed592 | |||
| 9245b7fe5d | |||
| bca72234be | |||
| d3d77688bf | |||
| a1fb0f1db7 | |||
| 2f58426385 | |||
| f495ce4340 | |||
| cace5993d2 | |||
| d0da28209e | |||
| ea30ac3eb9 | |||
| 1ff9963209 | |||
| 1e00024ca2 | |||
| e685bef532 | |||
| 4b2d61ef2d | |||
| d79d739200 | |||
| 08281b9302 | |||
| 95b85b9ad4 | |||
| d1ff6b6311 | |||
| fe159efc5e | |||
| 92b83fc7ba | |||
| f828e21b39 | |||
| 581b394d46 | |||
| 7f120f3a7e | |||
| 7c4714db36 | |||
| 7c3f8e6297 | |||
| cb416fffd4 | |||
| a46644abd3 | |||
| 660cca6fc4 | |||
| ef9715f54a | |||
| b38132d3b7 | |||
| 1b00569cb2 | |||
| 4e2539167a | |||
| dff7d33461 | |||
| ec228788ca | |||
| 83b6ce7648 | |||
| 7f669680cd | |||
| 1e2e201eff | |||
| b2fcfe5f18 | |||
| 9d9c3ff1e8 | |||
| 071d096314 | |||
| 983971ec83 | |||
| 2adcffd95f | |||
| bd3734a68c | |||
| 0a0eefaf3f | |||
| 2b65d5aedd | |||
| 77f5fc68c8 | |||
| fd79bde4ab | |||
| a99b0230f4 | |||
| 81e41e2f6c | |||
| 97ff250465 | |||
| f8700ee017 | |||
| d7a009cade | |||
| a2d8feebb3 | |||
| e6f9b4c01d | |||
| 9682f30fd6 | |||
| 5c85cb5575 | |||
| 4bc93381d4 | |||
| a41c62548a | |||
| fd028b6d6c | |||
| 01dd2d52c3 | |||
| 3f777eb1cb | |||
| ebfb5150e7 | |||
| aed56e7717 | |||
| 7f4f69620b | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| bffeb55a7a | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| dd05061829 | |||
| 8f6b99c550 | |||
| f54ee86591 | |||
| 42e0ec2663 | |||
| 0456a97b35 | |||
| 07c609cc3a | |||
| de5d26403f | |||
| 73c2d0efac | |||
| d3c1c440cc | |||
| 94195c636f | |||
| 9abf492362 | |||
| defc84c216 | |||
| 3c9ae39145 | |||
| 581f43f4c1 | |||
| 221d7e4829 | |||
| 706528f04b | |||
| f95a96dd1f | |||
| d85c16ce0f | |||
| 35afdf4be4 | |||
| eb5ed86019 | |||
| 0cfa6f56be | |||
| 5af88ead33 | |||
| 8ec63ee610 | |||
| c8247bf7a0 | |||
| 2f3270c7ff | |||
| 960d60f0bc |
@@ -14,6 +14,17 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
@@ -23,89 +34,141 @@
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/63a445a956fa71ea347ad3695a62d543e14e341933326b9dbb9a15d79614ef58)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. When opening the Store for the first time, you will be asked to enter an **Extension Repository URL**
|
||||
|
||||
1. Open the **Store** tab in the app
|
||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
<details>
|
||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||
<br>
|
||||
|
||||
**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.
|
||||
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 are some tracks downloading in lower quality?**
|
||||
A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
|
||||
</details>
|
||||
|
||||
**Q: Can I download playlists?**
|
||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
<details>
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
**Q: Why do I need to grant storage permission?**
|
||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
</details>
|
||||
|
||||
**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.
|
||||
<details>
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
**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:
|
||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||
- **Tidal** up to 24-bit/192kHz
|
||||
- **Qobuz** up to 24-bit/192kHz
|
||||
- **Deezer** up to 16-bit/44.1kHz
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I download playlists?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||
<br>
|
||||
|
||||
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Is this app safe?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||
<br>
|
||||
|
||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Add the official source to receive updates directly within the app. 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.
|
||||
```
|
||||
|
||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
</details>
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to all the amazing people who have contributed to SpotiFLAC Mobile!
|
||||
Thanks to everyone who has 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.
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
Interested in contributing? Check out our [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
---
|
||||
|
||||
## 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) | |
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStableLibraryId(filePath: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
|
||||
val hex = bytes.joinToString("") { "%02x".format(it) }
|
||||
return "lib_$hex"
|
||||
}
|
||||
|
||||
data class SafScanProgress(
|
||||
var totalFiles: Int = 0,
|
||||
var scannedFiles: Int = 0,
|
||||
@@ -1263,7 +1271,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} else {
|
||||
try {
|
||||
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
|
||||
metadataObj.put("filePath", doc.uri.toString())
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", lastModified)
|
||||
results.put(metadataObj)
|
||||
} catch (_: Exception) {
|
||||
@@ -1680,7 +1690,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} else {
|
||||
try {
|
||||
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
|
||||
metadataObj.put("filePath", doc.uri.toString())
|
||||
val stableUri = doc.uri.toString()
|
||||
metadataObj.put("id", buildStableLibraryId(stableUri))
|
||||
metadataObj.put("filePath", stableUri)
|
||||
metadataObj.put("fileModTime", safeLastModified)
|
||||
metadataObj.put("lastModified", safeLastModified)
|
||||
results.put(metadataObj)
|
||||
@@ -2630,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Tidal search API
|
||||
"searchTidalAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Qobuz search API
|
||||
"searchQobuzAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"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",
|
||||
"version": "3.9.0",
|
||||
"versionDate": "2026-03-25",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-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
|
||||
"size": 34477323
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1594,7 +1594,19 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
||||
return extractOggCoverArt(filePath)
|
||||
|
||||
case ".m4a":
|
||||
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
|
||||
data, err := extractCoverFromM4A(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := "image/jpeg"
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47 {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
|
||||
+67
-3
@@ -128,6 +128,7 @@ type DownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
@@ -214,6 +215,11 @@ func buildDownloadSuccessResponse(
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
coverURL := strings.TrimSpace(result.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(req.CoverURL)
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
@@ -230,7 +236,7 @@ func buildDownloadSuccessResponse(
|
||||
TrackNumber: trackNumber,
|
||||
DiscNumber: discNumber,
|
||||
ISRC: isrc,
|
||||
CoverURL: req.CoverURL,
|
||||
CoverURL: coverURL,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
@@ -378,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
}
|
||||
@@ -586,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
LyricsLRC: qobuzResult.LyricsLRC,
|
||||
}
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
@@ -739,6 +747,26 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
result["artist"] = meta.Artist
|
||||
result["album"] = meta.Album
|
||||
result["album_artist"] = meta.AlbumArtist
|
||||
result["date"] = meta.Date
|
||||
if meta.Date == "" {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
result["label"] = meta.Label
|
||||
result["copyright"] = meta.Copyright
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
}
|
||||
quality, qualityErr := GetM4AQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
@@ -1127,6 +1155,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
@@ -1960,8 +2018,15 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Fetch lyrics
|
||||
// Preserve existing lyrics when online enrichment does not return a replacement.
|
||||
var lyricsLRC string
|
||||
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
||||
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
||||
lyricsLRC = existingLyrics
|
||||
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
||||
}
|
||||
|
||||
// Fetch lyrics
|
||||
if req.EmbedLyrics {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(req.DurationMs) / 1000.0
|
||||
@@ -2042,7 +2107,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// MP3/Opus: return metadata map for Dart to use FFmpeg
|
||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||
cleanupCover = false
|
||||
result := map[string]interface{}{
|
||||
|
||||
@@ -84,3 +84,32 @@ func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||
t.Fatalf("disc number = %d", discNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"qobuz",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.CoverURL != result.CoverURL {
|
||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: qobuzResult.TrackNumber,
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
CoverURL: qobuzResult.CoverURL,
|
||||
}
|
||||
}
|
||||
err = qobuzErr
|
||||
@@ -1522,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
CoverURL: result.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
|
||||
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
return client
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
|
||||
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -293,7 +293,7 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" && ext != ".m4a" {
|
||||
if coverCacheDir != "" {
|
||||
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
@@ -373,13 +373,30 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err == nil && metadata != nil {
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return scanFromFilename(filePath, "", result)
|
||||
applyDefaultLibraryMetadata(filePath, "", result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
|
||||
+107
-64
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string {
|
||||
// GetAvailableLyricsProviders returns metadata about all available providers.
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"},
|
||||
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
|
||||
return now.Add(10 * time.Minute)
|
||||
}
|
||||
|
||||
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
if syncType == "" {
|
||||
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
|
||||
syncType = "LINE_SYNCED"
|
||||
} else {
|
||||
syncType = "UNSYNCED"
|
||||
}
|
||||
}
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: syncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: plainLyrics,
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func plainLyricsFromTimedLines(lines []LyricsLine) string {
|
||||
parts := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, words)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
trimmed := strings.TrimSpace(lrcPayload)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(trimmed)
|
||||
if len(lines) > 0 {
|
||||
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(trimmed)
|
||||
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
lines := make([]LyricsLine, 0, len(apiResp.Lines))
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
lines = append(lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
nextStart := lines[i+1].StartTimeMs
|
||||
if nextStart > lines[i].StartTimeMs {
|
||||
lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
last := len(lines) - 1
|
||||
if lines[last].EndTimeMs == 0 {
|
||||
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
|
||||
now := time.Now()
|
||||
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
|
||||
@@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
spotifyID = parsed.ID
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID))
|
||||
apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
|
||||
setSpotifyLyricsRateLimitUntil(retryUntil)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil {
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err == nil {
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
|
||||
}
|
||||
@@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp SpotifyLyricsAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Error {
|
||||
msg := strings.TrimSpace(apiResp.Message)
|
||||
if msg == "" {
|
||||
msg = "Spotify Lyrics API returned error"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
result := &LyricsResponse{
|
||||
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
|
||||
SyncType: apiResp.SyncType,
|
||||
Instrumental: false,
|
||||
PlainLyrics: "",
|
||||
Provider: "Spotify Lyrics API",
|
||||
Source: "Spotify Lyrics API",
|
||||
}
|
||||
|
||||
for _, line := range apiResp.Lines {
|
||||
words := strings.TrimSpace(line.Words)
|
||||
if words == "" {
|
||||
continue
|
||||
}
|
||||
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
|
||||
result.Lines = append(result.Lines, LyricsLine{
|
||||
StartTimeMs: startMs,
|
||||
Words: words,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(result.Lines) > 1 {
|
||||
for i := 0; i < len(result.Lines)-1; i++ {
|
||||
nextStart := result.Lines[i+1].StartTimeMs
|
||||
if nextStart > result.Lines[i].StartTimeMs {
|
||||
result.Lines[i].EndTimeMs = nextStart
|
||||
}
|
||||
}
|
||||
last := len(result.Lines) - 1
|
||||
if result.Lines[last].EndTimeMs == 0 {
|
||||
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.Lines) == 0 {
|
||||
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
|
||||
}
|
||||
|
||||
if result.SyncType == "" {
|
||||
result.SyncType = "LINE_SYNCED"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return parseSpotifyLyricsResponseBody(bodyBytes)
|
||||
}
|
||||
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
|
||||
+65
-126
@@ -4,121 +4,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppleMusicClient fetches lyrics from Apple Music.
|
||||
// Uses a scraped JWT token for search and a proxy for lyrics.
|
||||
// Uses Paxsenix endpoints for search and lyrics.
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Apple Music token manager — singleton with mutex for thread safety
|
||||
type appleTokenManager struct {
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
var globalAppleTokenManager = &appleTokenManager{}
|
||||
|
||||
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.token != "" {
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
// Step 1: Fetch the Apple Music beta page
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Find the index JS file URL
|
||||
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
|
||||
match := indexJsRegex.Find(body)
|
||||
if match == nil {
|
||||
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
|
||||
}
|
||||
|
||||
indexJsURL := "https://beta.music.apple.com" + string(match)
|
||||
|
||||
// Step 3: Fetch the JS file
|
||||
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create JS request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
jsResp, err := client.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Extract JWT token (starts with eyJh)
|
||||
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
|
||||
tokenMatch := tokenRegex.Find(jsBody)
|
||||
if tokenMatch == nil {
|
||||
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
|
||||
}
|
||||
|
||||
m.token = string(tokenMatch)
|
||||
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
|
||||
return m.token, nil
|
||||
}
|
||||
|
||||
func (m *appleTokenManager) clearToken() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.token = ""
|
||||
}
|
||||
|
||||
type appleMusicSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
URL string `json:"url"`
|
||||
Artwork struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"artwork"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
|
||||
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
if normalizedArtist == "" {
|
||||
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := 0
|
||||
|
||||
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
|
||||
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
|
||||
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && result.Duration > 0 {
|
||||
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
// SearchSong searches for a song on Apple Music and returns its ID.
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) {
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
token, err := globalAppleTokenManager.getToken(c.httpClient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music token error: %w", err)
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf(
|
||||
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
|
||||
encodedQuery,
|
||||
)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
globalAppleTokenManager.clearToken()
|
||||
return "", fmt.Errorf("apple music token expired")
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicSearchResponse
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 {
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.ID) == "" {
|
||||
return "", fmt.Errorf("no songs found on apple music")
|
||||
}
|
||||
|
||||
return searchResp.Results.Songs.Data[0].ID, nil
|
||||
return strings.TrimSpace(best.ID), nil
|
||||
}
|
||||
|
||||
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
|
||||
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName)
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
baseURL: "http://158.180.60.95",
|
||||
baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
|
||||
}
|
||||
}
|
||||
|
||||
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call.
|
||||
// The Musixmatch proxy returns both search result and lyrics in a single response.
|
||||
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
|
||||
func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||
return nil, fmt.Errorf("empty track or artist name")
|
||||
return "", fmt.Errorf("empty track or artist name")
|
||||
}
|
||||
|
||||
encodedArtist := url.QueryEscape(artistName)
|
||||
encodedTrack := url.QueryEscape(trackName)
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack)
|
||||
params := url.Values{}
|
||||
params.Set("t", trackName)
|
||||
params.Set("a", artistName)
|
||||
params.Set("type", lyricsType)
|
||||
params.Set("format", "lrc")
|
||||
if durationSec > 0 {
|
||||
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
|
||||
}
|
||||
if strings.TrimSpace(language) != "" {
|
||||
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
|
||||
}
|
||||
fullURL := c.baseURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch search failed: %w", err)
|
||||
return "", fmt.Errorf("musixmatch request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err)
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal(body, &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return "", fmt.Errorf("empty musixmatch lyrics payload")
|
||||
}
|
||||
return lrcPayload, nil
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to decode musixmatch response")
|
||||
}
|
||||
|
||||
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) {
|
||||
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
|
||||
lang := strings.ToLower(strings.TrimSpace(language))
|
||||
if songID <= 0 || lang == "" {
|
||||
return nil, fmt.Errorf("invalid song id or language")
|
||||
if lang == "" {
|
||||
return nil, fmt.Errorf("invalid language")
|
||||
}
|
||||
|
||||
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang))
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result musixmatchSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: fmt.Sprintf("Musixmatch (%s)", lang),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
|
||||
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
|
||||
|
||||
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
|
||||
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
|
||||
result, err := c.searchAndGetLyrics(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
|
||||
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
|
||||
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
|
||||
if localizedErr == nil {
|
||||
return localized, nil
|
||||
}
|
||||
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
|
||||
}
|
||||
|
||||
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" {
|
||||
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
|
||||
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: result.UnsyncedLyrics.Lyrics,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
plainLines := plainTextLyricsLines(lrcText)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: lrcText,
|
||||
Provider: "Musixmatch",
|
||||
Source: "Musixmatch",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no lyrics found on musixmatch")
|
||||
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com).
|
||||
// This is a direct public API — no proxy dependency.
|
||||
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
|
||||
type NeteaseClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
return 0, fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
searchURL := "http://music.163.com/api/search/pc"
|
||||
searchURL := "https://lyrics.paxsenix.org/netease/search"
|
||||
params := url.Values{}
|
||||
params.Set("s", query)
|
||||
params.Set("type", "1")
|
||||
params.Set("limit", "1")
|
||||
params.Set("offset", "0")
|
||||
params.Set("q", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
|
||||
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
|
||||
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
|
||||
lyricsURL := "http://music.163.com/api/song/lyric"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
|
||||
params := url.Values{}
|
||||
params.Set("id", fmt.Sprintf("%d", songID))
|
||||
params.Set("lv", "1")
|
||||
params.Set("tv", "1")
|
||||
params.Set("rv", "1")
|
||||
|
||||
fullURL := lyricsURL + "?" + params.Encode()
|
||||
|
||||
|
||||
@@ -1,45 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QQMusicClient fetches lyrics from QQ Music.
|
||||
// Search uses public QQ Music API, lyrics use the paxsenix proxy.
|
||||
// Uses Paxsenix metadata lookup for lyrics.
|
||||
type QQMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type qqMusicSearchResponse struct {
|
||||
Data struct {
|
||||
Song struct {
|
||||
List []struct {
|
||||
Title string `json:"title"`
|
||||
Singer []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"singer"`
|
||||
Album struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
} `json:"list"`
|
||||
} `json:"song"`
|
||||
} `json:"data"`
|
||||
type qqLyricsMetadataRequest struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album,omitempty"`
|
||||
SongID int64 `json:"songid,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// QQ Music lyrics request payload for paxsenix proxy
|
||||
type qqLyricsPayload struct {
|
||||
Artist []string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
type qqLyricsMetadataResponse struct {
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewQQMusicClient() *QQMusicClient {
|
||||
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
|
||||
}
|
||||
}
|
||||
|
||||
// searchSong searches QQ Music and returns the song info needed for lyrics fetch.
|
||||
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("empty search query")
|
||||
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
|
||||
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
|
||||
payload := qqLyricsMetadataRequest{
|
||||
Artist: []string{artistName},
|
||||
Title: trackName,
|
||||
}
|
||||
if durationSec > 0 {
|
||||
payload.Duration = int64(math.Round(durationSec))
|
||||
}
|
||||
|
||||
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp"
|
||||
params := url.Values{}
|
||||
params.Set("format", "json")
|
||||
params.Set("inCharset", "utf8")
|
||||
params.Set("outCharset", "utf8")
|
||||
params.Set("platform", "yqq.json")
|
||||
params.Set("new_json", "1")
|
||||
params.Set("w", query)
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("qqmusic search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp qqMusicSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
|
||||
}
|
||||
|
||||
if len(searchResp.Data.Song.List) == 0 {
|
||||
return nil, fmt.Errorf("no songs found on qqmusic")
|
||||
}
|
||||
|
||||
song := searchResp.Data.Song.List[0]
|
||||
|
||||
var artists []string
|
||||
for _, singer := range song.Singer {
|
||||
artists = append(artists, singer.Name)
|
||||
}
|
||||
|
||||
return &qqLyricsPayload{
|
||||
Artist: artists,
|
||||
Album: song.Album.Name,
|
||||
ID: song.ID,
|
||||
Title: song.Title,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
|
||||
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
|
||||
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
|
||||
lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes))
|
||||
req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
var response qqLyricsMetadataResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
|
||||
}
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
}
|
||||
|
||||
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
trackName,
|
||||
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
) (*LyricsResponse, error) {
|
||||
payload, err := c.searchSong(trackName, artistName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawLyrics, err := c.fetchLyricsByPayload(payload)
|
||||
rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try to parse as pax format (word-by-word or line)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
// If pax parsing fails, try to use as direct LRC text
|
||||
lrcText = rawLyrics
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
}
|
||||
|
||||
lines := parseSyncedLyrics(lrcText)
|
||||
|
||||
+237
-91
@@ -589,78 +589,117 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
func extractLyricsFromM4A(filePath string) (string, error) {
|
||||
func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ilst, err := findM4AIlstAtom(f, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
start := ilst.offset + ilst.headerSize
|
||||
end := ilst.offset + ilst.size
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fi.Size())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return nil, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "\xa9nam":
|
||||
metadata.Title, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9ART":
|
||||
metadata.Artist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9alb":
|
||||
metadata.Album, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "aART":
|
||||
metadata.AlbumArtist, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9day":
|
||||
metadata.Date, _ = readM4ATextValue(f, header, fi.Size())
|
||||
metadata.Year = metadata.Date
|
||||
case "\xa9gen":
|
||||
metadata.Genre, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9wrt":
|
||||
metadata.Composer, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9cmt":
|
||||
metadata.Comment, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "cprt":
|
||||
metadata.Copyright, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "\xa9lyr":
|
||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "trkn":
|
||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "disk":
|
||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||
if freeformErr == nil {
|
||||
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LABEL", "ORGANIZATION":
|
||||
metadata.Label = value
|
||||
case "COMMENT":
|
||||
if metadata.Comment == "" {
|
||||
metadata.Comment = value
|
||||
}
|
||||
case "COMPOSER":
|
||||
if metadata.Composer == "" {
|
||||
metadata.Composer = value
|
||||
}
|
||||
case "COPYRIGHT":
|
||||
if metadata.Copyright == "" {
|
||||
metadata.Copyright = value
|
||||
}
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if metadata.Title == "" &&
|
||||
metadata.Artist == "" &&
|
||||
metadata.Album == "" &&
|
||||
metadata.AlbumArtist == "" &&
|
||||
metadata.Lyrics == "" &&
|
||||
metadata.TrackNumber == 0 &&
|
||||
metadata.DiscNumber == 0 {
|
||||
return nil, fmt.Errorf("no M4A tags found")
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromM4A(filePath string) (string, error) {
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return "", fmt.Errorf("moov not found")
|
||||
if metadata == nil || strings.TrimSpace(metadata.Lyrics) == "" {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
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
|
||||
return metadata.Lyrics, nil
|
||||
}
|
||||
|
||||
func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||
@@ -676,37 +715,13 @@ func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("moov not found")
|
||||
ilst, err := findM4AIlstAtom(f, fileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
bodyStart := ilst.offset + ilst.headerSize
|
||||
bodySize := ilst.size - ilst.headerSize
|
||||
|
||||
covr, found, err := findAtomInRange(f, bodyStart, bodySize, "covr", fileSize)
|
||||
if err != nil || !found {
|
||||
@@ -736,6 +751,137 @@ func extractCoverFromM4A(filePath string) ([]byte, error) {
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// findM4AIlstAtom locates the ilst atom that holds all iTunes-style tags.
|
||||
// It tries two common layouts:
|
||||
// 1. moov > udta > meta > ilst (iTunes, FFmpeg default)
|
||||
// 2. moov > meta > ilst (some encoders omit the udta wrapper)
|
||||
func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return atomHeader{}, fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
moovBodyStart := moov.offset + moov.headerSize
|
||||
moovBodySize := moov.size - moov.headerSize
|
||||
|
||||
// Path 1: moov > udta > meta > ilst
|
||||
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
|
||||
udtaBodyStart := udta.offset + udta.headerSize
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path 2: moov > meta > ilst (no udta wrapper)
|
||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
|
||||
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||
}
|
||||
|
||||
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
||||
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
||||
if payloadLen <= 0 {
|
||||
return nil, fmt.Errorf("empty data atom in %s", dataAtom.typ)
|
||||
}
|
||||
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, err := f.ReadAt(buf, payloadStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func readM4ADataPayload(f *os.File, parent atomHeader, fileSize int64) ([]byte, error) {
|
||||
dataStart := parent.offset + parent.headerSize
|
||||
dataSize := parent.size - parent.headerSize
|
||||
|
||||
dataAtom, found, err := findAtomInRange(f, dataStart, dataSize, "data", fileSize)
|
||||
if err != nil || !found {
|
||||
return nil, fmt.Errorf("data atom not found in %s", parent.typ)
|
||||
}
|
||||
return readM4ADataAtomPayload(f, dataAtom)
|
||||
}
|
||||
|
||||
func readM4ATextValue(f *os.File, parent atomHeader, fileSize int64) (string, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimRight(string(payload), "\x00")), nil
|
||||
}
|
||||
|
||||
func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(payload) < 4 {
|
||||
return 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
||||
}
|
||||
|
||||
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
||||
start := parent.offset + parent.headerSize
|
||||
end := parent.offset + parent.size
|
||||
|
||||
var nameValue string
|
||||
var dataValue string
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = end - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return "", "", fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
switch header.typ {
|
||||
case "mean":
|
||||
// Domain qualifier (e.g. "com.apple.iTunes") — not needed, skip.
|
||||
case "name":
|
||||
// The "name" atom payload is: 4-byte version/flags, then raw UTF-8 text.
|
||||
// It does NOT contain a nested "data" atom, so read the payload directly.
|
||||
payloadStart := header.offset + header.headerSize + 4
|
||||
payloadLen := header.size - header.headerSize - 4
|
||||
if payloadLen > 0 {
|
||||
buf := make([]byte, payloadLen)
|
||||
if _, readErr := f.ReadAt(buf, payloadStart); readErr == nil {
|
||||
nameValue = strings.TrimSpace(strings.TrimRight(string(buf), "\x00"))
|
||||
}
|
||||
}
|
||||
case "data":
|
||||
payload, payloadErr := readM4ADataAtomPayload(f, header)
|
||||
if payloadErr == nil {
|
||||
dataValue = strings.TrimSpace(strings.TrimRight(string(payload), "\x00"))
|
||||
}
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
if nameValue == "" || dataValue == "" {
|
||||
return "", "", fmt.Errorf("freeform M4A tag incomplete")
|
||||
}
|
||||
|
||||
return nameValue, dataValue, nil
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
|
||||
+151
-14
@@ -49,9 +49,10 @@ const (
|
||||
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
|
||||
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
|
||||
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
|
||||
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
|
||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
||||
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
|
||||
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||
@@ -478,8 +479,8 @@ func parseQobuzURL(input string) (string, string, error) {
|
||||
}
|
||||
|
||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
normExpected := normalizeLooseArtistName(expectedArtist)
|
||||
normFound := normalizeLooseArtistName(foundArtist)
|
||||
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
@@ -1306,6 +1307,134 @@ func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAll searches Qobuz for tracks, artists, and albums matching the query.
|
||||
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
|
||||
func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||
GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||
|
||||
cleanQuery := strings.TrimSpace(query)
|
||||
if cleanQuery == "" {
|
||||
return nil, fmt.Errorf("empty qobuz search query")
|
||||
}
|
||||
|
||||
albumLimit := 5
|
||||
|
||||
if filter != "" {
|
||||
switch filter {
|
||||
case "track":
|
||||
trackLimit = 50
|
||||
artistLimit = 0
|
||||
albumLimit = 0
|
||||
case "artist":
|
||||
trackLimit = 0
|
||||
artistLimit = 20
|
||||
albumLimit = 0
|
||||
case "album":
|
||||
trackLimit = 0
|
||||
artistLimit = 0
|
||||
albumLimit = 20
|
||||
}
|
||||
}
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||
Playlists: make([]SearchPlaylistResult, 0),
|
||||
}
|
||||
|
||||
if trackLimit > 0 {
|
||||
tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Track search failed: %v\n", err)
|
||||
return nil, fmt.Errorf("qobuz track search failed: %w", err)
|
||||
}
|
||||
GoLog("[Qobuz] Got %d tracks from API\n", len(tracks))
|
||||
for i := range tracks {
|
||||
result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i]))
|
||||
}
|
||||
}
|
||||
|
||||
if artistLimit > 0 {
|
||||
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s",
|
||||
url.QueryEscape(cleanQuery), artistLimit, q.appID)
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err == nil {
|
||||
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
||||
if reqErr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
var artistResp struct {
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Image qobuzImageSet `json:"image"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil {
|
||||
GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items))
|
||||
for _, artist := range artistResp.Artists.Items {
|
||||
imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail)
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: qobuzPrefixedNumericID(artist.ID),
|
||||
Name: strings.TrimSpace(artist.Name),
|
||||
Images: imageURL,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Artist search decode failed: %v\n", decErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Artist search request failed: %v\n", reqErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if albumLimit > 0 {
|
||||
searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
|
||||
url.QueryEscape(cleanQuery), albumLimit, q.appID)
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err == nil {
|
||||
resp, reqErr := DoRequestWithUserAgent(q.client, req)
|
||||
if reqErr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
var albumResp struct {
|
||||
Albums struct {
|
||||
Items []qobuzAlbumDetails `json:"items"`
|
||||
} `json:"albums"`
|
||||
}
|
||||
if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil {
|
||||
GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items))
|
||||
for i := range albumResp.Albums.Items {
|
||||
album := &albumResp.Albums.Items[i]
|
||||
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||
ID: qobuzPrefixedID(album.ID),
|
||||
Name: strings.TrimSpace(album.Title),
|
||||
Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name),
|
||||
Images: qobuzAlbumImage(album),
|
||||
ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal),
|
||||
TotalTracks: album.TracksCount,
|
||||
AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Album search decode failed: %v\n", decErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GoLog("[Qobuz] Album search request failed: %v\n", reqErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
queries := []string{}
|
||||
|
||||
@@ -1631,19 +1760,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
|
||||
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
|
||||
}
|
||||
|
||||
func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
|
||||
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
||||
payload := map[string]any{
|
||||
"quality": requestQuality,
|
||||
"upload_to_r2": false,
|
||||
"url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
|
||||
var lastErr error
|
||||
retryDelay := qobuzRetryDelay
|
||||
var payloadBytes []byte
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
requestQuality := mapQobuzQualityCodeToAPI(quality)
|
||||
payload := map[string]any{
|
||||
"quality": requestQuality,
|
||||
"upload_to_r2": false,
|
||||
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
|
||||
}
|
||||
var err error
|
||||
payloadBytes, err = json.Marshal(payload)
|
||||
payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
|
||||
if err != nil {
|
||||
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
|
||||
}
|
||||
@@ -1688,7 +1821,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
|
||||
}
|
||||
if provider.Kind == qobuzAPIKindMusicDL {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
@@ -1935,6 +2067,7 @@ type QobuzDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
@@ -1996,8 +2129,8 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
|
||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
|
||||
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID but no ISRC
|
||||
if track == nil && req.SpotifyID != "" && req.QobuzID == "" && req.ISRC == "" {
|
||||
GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID)
|
||||
songLinkClient := NewSongLinkClient()
|
||||
availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC)
|
||||
@@ -2128,7 +2261,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
coverURL := strings.TrimSpace(req.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
|
||||
}
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
@@ -2261,6 +2397,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseQobuzURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -195,6 +198,28 @@ func TestGetQobuzDebugKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
|
||||
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
|
||||
}
|
||||
if got := payload["quality"]; got != "hi-res" {
|
||||
t.Fatalf("payload quality = %v, want hi-res", got)
|
||||
}
|
||||
if got := payload["upload_to_r2"]; got != false {
|
||||
t.Fatalf("payload upload_to_r2 = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
body := []byte(`
|
||||
<button data-itemtype="album" data-itemId="0886446451985"></button>
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||
redacted := sanitizeSensitiveLogText(input)
|
||||
|
||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||
}
|
||||
|
||||
blocked := []string{
|
||||
"http://accounts.example.com/oauth/authorize",
|
||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||
"https://localhost/oauth/authorize",
|
||||
}
|
||||
|
||||
for _, rawURL := range blocked {
|
||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||
t.Fatal("expected embedded URL credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||
if err != nil {
|
||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||
}
|
||||
|
||||
if !isPathWithinBase(baseDir, destPath) {
|
||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(destPath)
|
||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||
}
|
||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||
}
|
||||
|
||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||
t.Fatal("expected empty extension id to be rejected")
|
||||
}
|
||||
}
|
||||
+280
-13
@@ -26,8 +26,14 @@ type TidalDownloader struct {
|
||||
}
|
||||
|
||||
var (
|
||||
globalTidalDownloader *TidalDownloader
|
||||
tidalDownloaderOnce sync.Once
|
||||
globalTidalDownloader *TidalDownloader
|
||||
tidalDownloaderOnce sync.Once
|
||||
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
|
||||
return t.getTrackSearchPage(query, limit)
|
||||
}
|
||||
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
|
||||
return t.getPublicTrack(resourceID)
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -758,15 +764,101 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode")
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return nil, fmt.Errorf("empty tidal ISRC")
|
||||
}
|
||||
|
||||
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range page.Items {
|
||||
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
|
||||
return &page.Items[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||
queryParts := make([]string, 0, 3)
|
||||
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
|
||||
queryParts = append(queryParts, trimmed)
|
||||
}
|
||||
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
|
||||
queryParts = append(queryParts, trimmed)
|
||||
}
|
||||
if len(queryParts) == 0 {
|
||||
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
|
||||
}
|
||||
|
||||
queries := []string{strings.Join(queryParts, " ")}
|
||||
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
|
||||
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
TrackName: strings.TrimSpace(trackName),
|
||||
ArtistName: strings.TrimSpace(artistName),
|
||||
AlbumName: strings.TrimSpace(albumName),
|
||||
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
|
||||
DurationMS: expectedDuration * 1000,
|
||||
}
|
||||
|
||||
seenQueries := make(map[string]struct{}, len(queries))
|
||||
for _, query := range queries {
|
||||
if _, seen := seenQueries[query]; seen {
|
||||
continue
|
||||
}
|
||||
seenQueries[query] = struct{}{}
|
||||
|
||||
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []*TidalTrack
|
||||
for i := range page.Items {
|
||||
track := &page.Items[i]
|
||||
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
|
||||
continue
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: strings.TrimSpace(track.Title),
|
||||
ArtistName: tidalTrackArtistsDisplay(track),
|
||||
Duration: track.Duration,
|
||||
}
|
||||
if trackMatchesRequest(req, resolved, "Tidal search") {
|
||||
candidates = append(candidates, track)
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if req.AlbumName != "" {
|
||||
for _, candidate := range candidates {
|
||||
if titlesMatch(req.AlbumName, candidate.Album.Title) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
if req.ISRC != "" {
|
||||
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
|
||||
}
|
||||
return nil, fmt.Errorf("no tidal metadata match found")
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode")
|
||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
@@ -782,6 +874,121 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
|
||||
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
|
||||
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
|
||||
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
|
||||
|
||||
cleanQuery := strings.TrimSpace(query)
|
||||
if cleanQuery == "" {
|
||||
return nil, fmt.Errorf("empty tidal search query")
|
||||
}
|
||||
|
||||
albumLimit := 5
|
||||
|
||||
if filter != "" {
|
||||
switch filter {
|
||||
case "track":
|
||||
trackLimit = 50
|
||||
artistLimit = 0
|
||||
albumLimit = 0
|
||||
case "artist":
|
||||
trackLimit = 0
|
||||
artistLimit = 20
|
||||
albumLimit = 0
|
||||
case "album":
|
||||
trackLimit = 0
|
||||
artistLimit = 0
|
||||
albumLimit = 20
|
||||
}
|
||||
}
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, trackLimit),
|
||||
Artists: make([]SearchArtistResult, 0, artistLimit),
|
||||
Albums: make([]SearchAlbumResult, 0, albumLimit),
|
||||
Playlists: make([]SearchPlaylistResult, 0),
|
||||
}
|
||||
|
||||
if trackLimit > 0 {
|
||||
page, err := t.getTrackSearchPage(cleanQuery, trackLimit)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Track search failed: %v\n", err)
|
||||
return nil, fmt.Errorf("tidal track search failed: %w", err)
|
||||
}
|
||||
GoLog("[Tidal] Got %d tracks from API\n", len(page.Items))
|
||||
for i := range page.Items {
|
||||
result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i]))
|
||||
}
|
||||
}
|
||||
|
||||
if artistLimit > 0 {
|
||||
requestURL := tidalBuildMetadataURL("search/artists", url.Values{
|
||||
"query": {cleanQuery},
|
||||
"limit": {strconv.Itoa(artistLimit)},
|
||||
"offset": {"0"},
|
||||
})
|
||||
var artistResp struct {
|
||||
Items []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
Popularity int `json:"popularity"`
|
||||
URL string `json:"url"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil {
|
||||
GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items))
|
||||
for _, artist := range artistResp.Items {
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: tidalPrefixedNumericID(artist.ID),
|
||||
Name: strings.TrimSpace(artist.Name),
|
||||
Images: tidalImageURL(artist.Picture, "750x750"),
|
||||
Followers: 0,
|
||||
Popularity: artist.Popularity,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Tidal] Artist search failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if albumLimit > 0 {
|
||||
requestURL := tidalBuildMetadataURL("search/albums", url.Values{
|
||||
"query": {cleanQuery},
|
||||
"limit": {strconv.Itoa(albumLimit)},
|
||||
"offset": {"0"},
|
||||
})
|
||||
var albumResp struct {
|
||||
Items []tidalPublicAlbum `json:"items"`
|
||||
}
|
||||
if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil {
|
||||
GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items))
|
||||
for i := range albumResp.Items {
|
||||
album := &albumResp.Items[i]
|
||||
albumType := strings.ToLower(strings.TrimSpace(album.Type))
|
||||
if albumType == "" {
|
||||
albumType = "album"
|
||||
}
|
||||
result.Albums = append(result.Albums, SearchAlbumResult{
|
||||
ID: tidalPrefixedNumericID(album.ID),
|
||||
Name: strings.TrimSpace(album.Title),
|
||||
Artists: tidalAlbumArtistsDisplay(album),
|
||||
Images: tidalImageURL(album.Cover, "1280x1280"),
|
||||
ReleaseDate: strings.TrimSpace(album.ReleaseDate),
|
||||
TotalTracks: album.NumberOfTracks,
|
||||
AlbumType: albumType,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
GoLog("[Tidal] Album search failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) {
|
||||
track, err := t.getPublicTrack(resourceID)
|
||||
if err != nil {
|
||||
@@ -1537,8 +1744,8 @@ type TidalDownloadResult struct {
|
||||
}
|
||||
|
||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||
normSpotify := normalizeLooseArtistName(spotifyArtist)
|
||||
normTidal := normalizeLooseArtistName(tidalArtist)
|
||||
|
||||
if normSpotify == normTidal {
|
||||
return true
|
||||
@@ -1847,6 +2054,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" {
|
||||
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
|
||||
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
|
||||
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
|
||||
trackID = directTrack.ID
|
||||
gotTidalID = true
|
||||
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
|
||||
} else if directErr != nil {
|
||||
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
|
||||
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
|
||||
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.AlbumName,
|
||||
req.ISRC,
|
||||
expectedDurationSec,
|
||||
)
|
||||
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
|
||||
trackID = searchTrack.ID
|
||||
gotTidalID = true
|
||||
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
|
||||
} else if searchErr != nil {
|
||||
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
|
||||
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
|
||||
|
||||
@@ -1912,7 +2149,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
}
|
||||
|
||||
// Verify the resolved track matches the request.
|
||||
actualTrack, fetchErr := downloader.getPublicTrack(strconv.FormatInt(trackID, 10))
|
||||
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, 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.
|
||||
@@ -1987,7 +2224,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
outputExt = ".flac"
|
||||
if quality == "HIGH" {
|
||||
outputExt = ".m4a"
|
||||
} else {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
} else if !strings.HasPrefix(outputExt, ".") {
|
||||
outputExt = "." + outputExt
|
||||
}
|
||||
@@ -2001,7 +2242,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
m4aPath = outputPath
|
||||
} else {
|
||||
if outputExt == ".m4a" {
|
||||
if outputExt == ".m4a" || quality == "HIGH" {
|
||||
filename = sanitizeFilename(filename) + ".m4a"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
m4aPath = outputPath
|
||||
@@ -2014,8 +2255,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
if quality != "HIGH" {
|
||||
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2171,7 +2414,27 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
if quality == "HIGH" {
|
||||
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
|
||||
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
|
||||
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Tidal] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
@@ -2180,6 +2443,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
bitDepth := downloadInfo.BitDepth
|
||||
sampleRate := downloadInfo.SampleRate
|
||||
if quality == "HIGH" {
|
||||
bitDepth = 0
|
||||
sampleRate = 44100
|
||||
}
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
|
||||
@@ -3,6 +3,8 @@ package gobackend
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// normalizeLooseTitle collapses separators/punctuation so titles like
|
||||
@@ -33,6 +35,37 @@ func normalizeLooseTitle(title string) string {
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
// normalizeLooseArtistName folds diacritics and common separators so artist
|
||||
// verification is resilient to variants like "Özkent" vs "Ozkent".
|
||||
func normalizeLooseArtistName(name string) string {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(name))
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
decomposed := norm.NFD.String(trimmed)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(decomposed))
|
||||
|
||||
for _, r := range decomposed {
|
||||
switch {
|
||||
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
|
||||
continue
|
||||
case unicode.IsLetter(r), unicode.IsNumber(r):
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
b.WriteByte(' ')
|
||||
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
// Drop remaining punctuation/symbols for loose artist matching.
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(b.String()), " ")
|
||||
}
|
||||
|
||||
func hasAlphaNumericRunes(value string) bool {
|
||||
for _, r := range value {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
|
||||
@@ -367,6 +367,26 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchQobuzAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getDeezerRelatedArtists":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let artistId = args["artist_id"] as! String
|
||||
|
||||
@@ -3,24 +3,24 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.8.7';
|
||||
static const String buildNumber = '113';
|
||||
static const String version = '3.9.0';
|
||||
static const String buildNumber = '115';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
static const String copyright = '© 2026 SpotiFLAC';
|
||||
|
||||
|
||||
static const String mobileAuthor = 'zarzet';
|
||||
static const String originalAuthor = 'afkarxyz';
|
||||
|
||||
|
||||
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
|
||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String originalGithubUrl =
|
||||
'https://github.com/afkarxyz/SpotiFLAC';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
}
|
||||
|
||||
@@ -2323,7 +2323,7 @@ abstract class AppLocalizations {
|
||||
/// Default search provider option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default (Deezer/Spotify)'**
|
||||
/// **'Default (Deezer)'**
|
||||
String get extensionDefaultProvider;
|
||||
|
||||
/// Subtitle for default provider
|
||||
@@ -2596,6 +2596,66 @@ abstract class AppLocalizations {
|
||||
/// **'24-bit / up to 192kHz'**
|
||||
String get qualityHiResFlacMaxSubtitle;
|
||||
|
||||
/// Quality option label for Tidal lossy 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps'**
|
||||
String get downloadLossy320;
|
||||
|
||||
/// Setting title to pick output format for Tidal lossy downloads
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy Format'**
|
||||
String get downloadLossyFormat;
|
||||
|
||||
/// Title of the Tidal lossy format picker bottom sheet
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Lossy 320kbps Format'**
|
||||
String get downloadLossy320Format;
|
||||
|
||||
/// Description in the Tidal lossy format picker
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.'**
|
||||
String get downloadLossy320FormatDesc;
|
||||
|
||||
/// Tidal lossy format option - MP3 320kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'MP3 320kbps'**
|
||||
String get downloadLossyMp3;
|
||||
|
||||
/// Subtitle for MP3 320kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best compatibility, ~10MB per track'**
|
||||
String get downloadLossyMp3Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 256kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 256kbps'**
|
||||
String get downloadLossyOpus256;
|
||||
|
||||
/// Subtitle for Opus 256kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Best quality Opus, ~8MB per track'**
|
||||
String get downloadLossyOpus256Subtitle;
|
||||
|
||||
/// Tidal lossy format option - Opus 128kbps
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Opus 128kbps'**
|
||||
String get downloadLossyOpus128;
|
||||
|
||||
/// Subtitle for Opus 128kbps Tidal lossy option
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smallest size, ~4MB per track'**
|
||||
String get downloadLossyOpus128Subtitle;
|
||||
|
||||
/// Note about quality availability
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -4659,6 +4719,30 @@ abstract class AppLocalizations {
|
||||
/// **'Artist Name Filters'**
|
||||
String get downloadArtistNameFilters;
|
||||
|
||||
/// Setting title for adding a playlist folder prefix before the normal organization structure
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Create playlist source folder'**
|
||||
String get downloadCreatePlaylistSourceFolder;
|
||||
|
||||
/// Subtitle when playlist source folder prefix is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist downloads use Playlist/ plus your normal folder structure.'**
|
||||
String get downloadCreatePlaylistSourceFolderEnabled;
|
||||
|
||||
/// Subtitle when playlist source folder prefix is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playlist downloads use the normal folder structure only.'**
|
||||
String get downloadCreatePlaylistSourceFolderDisabled;
|
||||
|
||||
/// Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By Playlist already places downloads inside a playlist folder.'**
|
||||
String get downloadCreatePlaylistSourceFolderRedundant;
|
||||
|
||||
/// Setting title for SongLink country region
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
|
||||
'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Alben';
|
||||
@@ -441,7 +441,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get setupDownloadLocationIosMessage =>
|
||||
'Auf iOS werden Downloads im Dokumentenverzeichnis der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
||||
'Auf iOS werden Downloads im Dokumentenordner der App gespeichert. Du kannst sie über die Datei-App aufrufen.';
|
||||
|
||||
@override
|
||||
String get setupAppDocumentsFolder => 'App-Dokumentenordner';
|
||||
@@ -705,15 +705,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Keine Titel gefunden';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
String get errorUrlNotRecognized => 'Link wurde nicht erkannt';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
'Dieser Link ist inkompatibel. Prüfe die URL und stelle sicher, dass eine kompatible Erweiterung installiert ist.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
'Laden fehlgeschlagen. Bitte erneut versuchen.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -750,7 +750,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get selectionAllSelected => 'Alle Titel sind ausgewählt';
|
||||
|
||||
@override
|
||||
String get selectionSelectToDelete => 'Titel zum Löschen auswählen';
|
||||
String get selectionSelectToDelete => 'Titel zum Löschen wählen';
|
||||
|
||||
@override
|
||||
String progressFetchingMetadata(int current, int total) {
|
||||
@@ -767,7 +767,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get searchArtists => 'Künstler';
|
||||
|
||||
@override
|
||||
String get searchAlbums => 'Albums';
|
||||
String get searchAlbums => 'Alben';
|
||||
|
||||
@override
|
||||
String get searchPlaylists => 'Playlisten';
|
||||
@@ -789,11 +789,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get folderOrganizationNone => 'Keine Organisation';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'Nach Playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Ordner für jede Playlist trennen';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Nach Künstler';
|
||||
@@ -810,7 +810,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get folderOrganizationNoneSubtitle =>
|
||||
'Alle Dateien im Download-Verzeichnis';
|
||||
'Alle Dateien im Download-Ordner';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtistSubtitle =>
|
||||
@@ -1413,6 +1413,38 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
|
||||
@@ -1431,19 +1463,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
|
||||
|
||||
@override
|
||||
String get downloadDirectory => 'Downloadverzeichnis';
|
||||
String get downloadDirectory => 'Download-Ordner';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesFolder => 'Singles Ordner trennen';
|
||||
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
String get downloadAlbumFolderStructure => 'Album-Ordnerstruktur';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Album-Künstler für Ordner verwenden';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
String get downloadUsePrimaryArtistOnly => 'Primärer Künstler nur für Ordner';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
@@ -1451,7 +1484,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
'Vollständiger Künstler für Ordnername';
|
||||
|
||||
@override
|
||||
String get downloadSelectQuality => 'Qualität wählen';
|
||||
@@ -1473,7 +1506,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Bist du dir sicher, dass du alle Downloads löschen möchten?';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed =>
|
||||
'Auto-Export fehlgeschlagener Downloads';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
@@ -1496,14 +1530,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get albumFolderArtistAlbum => 'Künstler/Album';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSubtitle => 'Albums/Artist Name/Album Name/';
|
||||
String get albumFolderArtistAlbumSubtitle => 'Alben/Künster Name/Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbum => 'Artist / [Year] Album';
|
||||
String get albumFolderArtistYearAlbum => 'Künstler / [Year] Album';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistYearAlbumSubtitle =>
|
||||
'Albums/Künster Name/[2005] Album Name/';
|
||||
'Alben/Künster Name/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderAlbumOnly => 'Nur Alben';
|
||||
@@ -1515,14 +1549,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get albumFolderYearAlbum => '[Year] Album';
|
||||
|
||||
@override
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Album Name/';
|
||||
String get albumFolderYearAlbumSubtitle => 'Alben/[2005] Album Name/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
String get albumFolderArtistAlbumSingles => 'Künstler / Album + Singles';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
'Künstler/Album/ und Künstler/Singles/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
|
||||
@@ -1561,7 +1595,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
|
||||
String get downloadedAlbumSelectToDelete => 'Titel zum Löschen wählen';
|
||||
|
||||
@override
|
||||
String downloadedAlbumDiscHeader(int discNumber) {
|
||||
@@ -1607,7 +1641,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String discographyAlbumsOnlySubtitle(int count, int albumCount) {
|
||||
return '$count Titel von $albumCount Albums';
|
||||
return '$count Titel aus $albumCount Alben';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1623,14 +1657,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get discographySelectAlbumsSubtitle =>
|
||||
'Choose specific albums or singles';
|
||||
'Wähle bestimmte Alben oder Singles';
|
||||
|
||||
@override
|
||||
String get discographyFetchingTracks => 'Lade Titel...';
|
||||
|
||||
@override
|
||||
String discographyFetchingAlbum(int current, int total) {
|
||||
return 'Fetching $current of $total...';
|
||||
return 'Lade $current von $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1643,7 +1677,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return 'Added $count tracks to queue';
|
||||
return '$count Titel zur Warteschlange hinzugefügt';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1655,7 +1689,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get discographyNoAlbums => 'Es sind keine Alben verfügbar';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
String get discographyFailedToFetch => 'Fehler beim Abrufen einiger Alben';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Speicherzugriff';
|
||||
@@ -1664,14 +1698,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get allFilesAccess => 'Zugriff auf alle Dateien';
|
||||
|
||||
@override
|
||||
String get allFilesAccessEnabledSubtitle => 'Can write to any folder';
|
||||
String get allFilesAccessEnabledSubtitle => 'Darf in jeden Ordner schreiben';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDisabledSubtitle => 'Limited to media folders only';
|
||||
String get allFilesAccessDisabledSubtitle => 'Nur auf Medienordner begrenzt';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDescription =>
|
||||
'Aktiviere die Option, wenn beim Speichern in benutzerdefinierten Ordnern Schreibfehler auftreten. Weil Android 13+ standardmäßig den Zugriff auf bestimmte Verzeichnisse einschränkt.';
|
||||
'Option bei Schreibfehlern bitte aktivieren (erforderlich ab Android 13).';
|
||||
|
||||
@override
|
||||
String get allFilesAccessDeniedMessage =>
|
||||
@@ -1685,13 +1719,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get settingsLocalLibrary => 'Lokale Bibliothek';
|
||||
|
||||
@override
|
||||
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
|
||||
String get settingsLocalLibrarySubtitle =>
|
||||
'Musik scannen & Duplikate erkennen';
|
||||
|
||||
@override
|
||||
String get settingsCache => 'Speicher & Cache';
|
||||
|
||||
@override
|
||||
String get settingsCacheSubtitle => 'View size and clear cached data';
|
||||
String get settingsCacheSubtitle =>
|
||||
'Größe anzeigen und Daten im Cache leeren';
|
||||
|
||||
@override
|
||||
String get libraryTitle => 'Lokale Bibliothek';
|
||||
@@ -1704,7 +1740,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get libraryEnableLocalLibrarySubtitle =>
|
||||
'Scan and track your existing music';
|
||||
'Scan und verfolge deine bestehende Musik';
|
||||
|
||||
@override
|
||||
String get libraryFolder => 'Bibliotheksordner';
|
||||
@@ -1713,7 +1749,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get libraryFolderHint => 'Tippe um Ordner auszuwählen';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator';
|
||||
String get libraryShowDuplicateIndicator => 'Duplikat Indikator anzeigen';
|
||||
|
||||
@override
|
||||
String get libraryShowDuplicateIndicatorSubtitle =>
|
||||
@@ -1914,7 +1950,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Hole dir FLAC Audio von Tidal, Qobuz oder Amazon Musik';
|
||||
'Hole dir FLAC Audio von Tidal, Qobuz oder Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -1981,7 +2017,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip1 =>
|
||||
'Downloadverzeichnis und Ordnerorganisation ändern';
|
||||
'Download-Ordner und Ordner-Organisation ändern';
|
||||
|
||||
@override
|
||||
String get tutorialSettingsTip2 =>
|
||||
@@ -2039,14 +2075,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get cacheSectionMaintenance => 'Wartung';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'App-Cache Verzeichnis';
|
||||
String get cacheAppDirectory => 'App-Cache Ordner';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectoryDesc =>
|
||||
'HTTP-Antworten, WebView Daten und andere temporäre App-Daten.';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectory => 'Temporäres Verzeichnis';
|
||||
String get cacheTempDirectory => 'Temporärer Ordner';
|
||||
|
||||
@override
|
||||
String get cacheTempDirectoryDesc =>
|
||||
@@ -2167,11 +2203,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String trackCoverSaved(String fileName) {
|
||||
return 'Cover art saved to $fileName';
|
||||
return 'Cover in $fileName gespeichert';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCoverNoSource => 'No cover art source available';
|
||||
String get trackCoverNoSource => 'Keine Cover Quelle vorhanden';
|
||||
|
||||
@override
|
||||
String trackLyricsSaved(String fileName) {
|
||||
@@ -2269,10 +2305,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackConvertFailed => 'Konvertierung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => 'CUE-Sheet aufteilen';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
String get cueSplitSubtitle => 'CUE+FLAC in einzelne Titel aufteilen';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
@@ -2281,40 +2317,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
return 'Künstler: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
return '$count Titel';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
String get cueSplitConfirmTitle => 'CUE-Album aufteilen';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
return 'Soll „$album“ in $count einzelne FLAC-Dateien aufgeteilt werden?\n\nDie Dateien werden im selben Ordner gespeichert.';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
return 'CUE-Sheet wird geteilt... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
return '$count Titel erfolgreich aufgeteilt';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
String get cueSplitFailed => 'CUE-Aufteilung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
String get cueSplitNoAudioFile =>
|
||||
'Audiodatei für dieses CUE-Sheet nicht gefunden';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
String get cueSplitButton => 'In Titel aufteilen';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Erstellen';
|
||||
@@ -2539,11 +2576,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Künstlerordner verwenden den Album-Interpreten, wenn verfügbar';
|
||||
'Interpret-Ordner verwenden Album-Interpret, sofern vorhanden';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Künstler-Ordner nur für Titel-Künstler';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
@@ -2712,6 +2749,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -1240,7 +1240,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
@@ -1389,6 +1389,38 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -2685,6 +2717,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -1389,6 +1389,38 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -2685,6 +2717,22 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -3278,7 +3326,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Álbumes';
|
||||
@@ -3613,6 +3661,17 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No se encontraron pistas';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'No se puede cargar $item: falta una fuente de extensión';
|
||||
@@ -3676,9 +3735,23 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
@override
|
||||
String get filenameFormat => 'Formato del nombre del archivo';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganizationNone => 'Ninguna organización';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Por Artista';
|
||||
|
||||
@@ -4265,6 +4338,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
|
||||
|
||||
@@ -4597,6 +4676,17 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -4723,7 +4813,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -5040,6 +5130,258 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
return 'Album: $album';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String downloadedAlbumDownloadedCount(int count) {
|
||||
return '$count descargado';
|
||||
|
||||
@@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1391,6 +1391,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1892,7 +1924,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2686,6 +2718,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1389,6 +1389,38 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1890,7 +1922,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2684,6 +2716,22 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
||||
'Unduh lagu-lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Album';
|
||||
@@ -769,21 +769,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get filenameFormat => 'Format Nama File';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
String get filenameShowAdvancedTags => 'Tampilkan tag lanjutan';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
'Aktifkan tag yang diformat untuk padding trek dan pola tanggal';
|
||||
|
||||
@override
|
||||
String get folderOrganizationNone => 'Tidak ada';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'Berdasarkan Daftar Putar';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Setiap daftar putar memerlukan folder terpisah';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Berdasarkan Artis';
|
||||
@@ -939,13 +939,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.';
|
||||
|
||||
@override
|
||||
String get credentialsClientId => 'Client ID';
|
||||
String get credentialsClientId => 'ID Klien';
|
||||
|
||||
@override
|
||||
String get credentialsClientIdHint => 'Tempel Client ID';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecret => 'Client Secret';
|
||||
String get credentialsClientSecret => 'Rahasia Klien';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecretHint => 'Tempel Client Secret';
|
||||
@@ -954,7 +954,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get channelStable => 'Stabil';
|
||||
|
||||
@override
|
||||
String get channelPreview => 'Preview';
|
||||
String get channelPreview => 'Pratinjau';
|
||||
|
||||
@override
|
||||
String get sectionSearchSource => 'Sumber Pencarian';
|
||||
@@ -984,33 +984,34 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get sectionFileSettings => 'Pengaturan File';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
String get sectionLyrics => 'Lirik';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
String get lyricsMode => 'Mode Lirik';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
'Pilih cara lirik disimpan bersama unduhan Anda';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
String get lyricsModeEmbed => 'Sematkan dalam file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
String get lyricsModeEmbedSubtitle =>
|
||||
'Lirik tersimpan di dalam metadata FLAC';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
String get lyricsModeExternal => 'File .lrc eksternal';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
'File .lrc terpisah untuk pemutar musik seperti Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Both';
|
||||
String get lyricsModeBoth => 'Keduanya';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
String get lyricsModeBothSubtitle => 'Sematkan dan simpan file .lrc';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Warna';
|
||||
@@ -1122,10 +1123,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Label';
|
||||
String get trackLabel => 'Lebel';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Copyright';
|
||||
String get trackCopyright => 'Hak cipta';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'Diunduh';
|
||||
@@ -1143,13 +1144,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackLyricsLoadFailed => 'Gagal memuat lirik';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
String get trackEmbedLyrics => 'Sematkan Lirik';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
String get trackLyricsEmbedded => 'Lirik berhasil disematkan';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
String get trackInstrumental => 'Lagu instrumental';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Disalin ke clipboard';
|
||||
@@ -1245,7 +1246,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
||||
@@ -1257,7 +1258,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get extensionId => 'ID';
|
||||
|
||||
@override
|
||||
String get extensionError => 'Error';
|
||||
String get extensionError => 'Terjadi kesalahan';
|
||||
|
||||
@override
|
||||
String get extensionCapabilities => 'Kemampuan';
|
||||
@@ -1396,19 +1397,51 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
|
||||
|
||||
@override
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
|
||||
@@ -1423,18 +1456,19 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Gunakan Artis Album untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
|
||||
String get downloadUsePrimaryArtistOnly => 'Hanya artis utama untuk folder';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Artis unggulan dihapus dari nama folder (misalnya Justin Bieber, Quavo → Justin Bieber)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
'Full artist string used for folder name';
|
||||
'Nama lengkap artis digunakan untuk nama folder';
|
||||
|
||||
@override
|
||||
String get downloadSelectQuality => 'Pilih Kualitas';
|
||||
@@ -1456,24 +1490,24 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Apakah Anda yakin ingin menghapus semua unduhan?';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailed => 'Auto-export failed downloads';
|
||||
String get settingsAutoExportFailed => 'Unduhan yang gagal diekspor otomatis';
|
||||
|
||||
@override
|
||||
String get settingsAutoExportFailedSubtitle =>
|
||||
'Save failed downloads to TXT file automatically';
|
||||
'Simpan unduhan yang gagal ke file TXT secara otomatis';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetwork => 'Download Network';
|
||||
String get settingsDownloadNetwork => 'Jaringan Unduhan';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Mobile Data';
|
||||
String get settingsDownloadNetworkAny => 'WiFi + Data Seluler';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkWifiOnly => 'WiFi Only';
|
||||
String get settingsDownloadNetworkWifiOnly => 'Hanya WiFi';
|
||||
|
||||
@override
|
||||
String get settingsDownloadNetworkSubtitle =>
|
||||
'Choose which network to use for downloads. When set to WiFi Only, downloads will pause on mobile data.';
|
||||
'Pilih jaringan mana yang akan digunakan untuk mengunduh. Jika diatur ke Hanya WiFi, unduhan akan berhenti sementara dan menggunakan data seluler.';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbum => 'Artis / Album';
|
||||
@@ -1501,11 +1535,11 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get albumFolderYearAlbumSubtitle => 'Albums/[2005] Nama Album/';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSingles => 'Artist / Album + Singles';
|
||||
String get albumFolderArtistAlbumSingles => 'Artis / Album + Singel';
|
||||
|
||||
@override
|
||||
String get albumFolderArtistAlbumSinglesSubtitle =>
|
||||
'Artist/Album/ and Artist/Singles/';
|
||||
'Artis/Album/ dan Artis/Single/';
|
||||
|
||||
@override
|
||||
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
|
||||
@@ -1561,21 +1595,21 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get recentTypeSong => 'Lagu';
|
||||
|
||||
@override
|
||||
String get recentTypePlaylist => 'Playlist';
|
||||
String get recentTypePlaylist => 'Daftar putar';
|
||||
|
||||
@override
|
||||
String get recentEmpty => 'No recent items yet';
|
||||
String get recentEmpty => 'Belum ada item terbaru';
|
||||
|
||||
@override
|
||||
String get recentShowAllDownloads => 'Show All Downloads';
|
||||
String get recentShowAllDownloads => 'Tampilkan Semua Unduhan';
|
||||
|
||||
@override
|
||||
String recentPlaylistInfo(String name) {
|
||||
return 'Playlist: $name';
|
||||
return 'Daftar Putar: $name';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyDownload => 'Download Discography';
|
||||
String get discographyDownload => 'Unduh Diskografi';
|
||||
|
||||
@override
|
||||
String get discographyDownloadAll => 'Unduh Semua';
|
||||
@@ -1885,44 +1919,44 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
|
||||
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeDesc =>
|
||||
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
|
||||
'Mari kita pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip1 =>
|
||||
'Download music from Spotify, Deezer, or paste any supported URL';
|
||||
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Dapatkan audio berkualitas FLAC dari Tidal, Qobuz, atau Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
'Automatic metadata, cover art, and lyrics embedding';
|
||||
'Penyematan metadata, sampul album, dan lirik secara otomatis';
|
||||
|
||||
@override
|
||||
String get tutorialSearchTitle => 'Finding Music';
|
||||
String get tutorialSearchTitle => 'Menemukan Musik';
|
||||
|
||||
@override
|
||||
String get tutorialSearchDesc =>
|
||||
'There are two easy ways to find music you want to download.';
|
||||
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadTitle => 'Downloading Music';
|
||||
String get tutorialDownloadTitle => 'Mengunduh Musik';
|
||||
|
||||
@override
|
||||
String get tutorialDownloadDesc =>
|
||||
'Downloading music is simple and fast. Here\'s how it works.';
|
||||
'Mengunduh musik itu mudah dan cepat. Begini cara kerjanya.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTitle => 'Your Library';
|
||||
String get tutorialLibraryTitle => 'Perpustakaan Anda';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryDesc =>
|
||||
'All your downloaded music is organized in the Library tab.';
|
||||
'Semua musik yang Anda unduh tersusun rapi di tab Perpustakaan.';
|
||||
|
||||
@override
|
||||
String get tutorialLibraryTip1 =>
|
||||
@@ -2692,6 +2726,22 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Buat folder sumber playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Unduhan dari playlist hanya memakai struktur folder normal.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'アルバム';
|
||||
@@ -761,7 +761,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get filenameFormat => 'ファイル名の形式';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
String get filenameShowAdvancedTags => '高度なタグを表示';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
@@ -1138,7 +1138,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
String get trackInstrumental => 'インストゥルメンタルのトラック';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'クリップボードにコピーしました';
|
||||
@@ -1379,6 +1379,38 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
|
||||
|
||||
@@ -1877,7 +1909,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2229,7 +2261,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackConvertFailed => '変換に失敗しました';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => '分割 CUE シート';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
@@ -2379,7 +2411,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
String get collectionRemoveFromFolder => 'フォルダから削除';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
@@ -2413,26 +2445,26 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
String get trackOptionAddToWishlist => 'ウィッシュリストに追加';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
String get trackOptionRemoveFromWishlist => 'ウィッシュから削除';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
String get collectionPlaylistChangeCover => 'カバー画像を変更';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
String get collectionPlaylistRemoveCover => 'カバー画像を削除';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: '個のトラック',
|
||||
one: '個のトラック',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
return '$count $_temp0を共有';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2453,7 +2485,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
String get selectionBatchConvertConfirmTitle => '一括変換';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
@@ -2671,6 +2703,22 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Tidal, Qobuz, Amazon Music에서 Spotify 트랙을 무손실 음질로 다운로드하세요.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => '앨범';
|
||||
@@ -1369,6 +1369,38 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1870,7 +1902,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2664,6 +2696,22 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -158,16 +158,16 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
String get optionsConcurrentSequential => 'Sequentiële (1 per keer)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count parallel downloads';
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
'Parallel downloaden kan leiden tot rate-limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Store';
|
||||
@@ -271,7 +271,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutContributors => 'Contributors';
|
||||
|
||||
@override
|
||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
||||
String get aboutMobileDeveloper => '';
|
||||
|
||||
@override
|
||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||
@@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -1389,6 +1389,38 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1890,7 +1922,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2684,6 +2716,22 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
@@ -1389,6 +1389,38 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -2685,6 +2717,22 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -3278,7 +3326,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Álbuns';
|
||||
@@ -3612,6 +3660,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Nenhuma faixa encontrada';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Não é possível carregar $item: faltando a fonte da extensão';
|
||||
@@ -3675,9 +3734,23 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
@override
|
||||
String get filenameFormat => 'Formato do Nome do Arquivo';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => 'Show advanced tags';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTagsDescription =>
|
||||
'Enable formatted tags for track padding and date patterns';
|
||||
|
||||
@override
|
||||
String get folderOrganizationNone => 'Nenhuma organização';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'Por Artista';
|
||||
|
||||
@@ -4262,6 +4335,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
String get youtubeQualityNote =>
|
||||
'YouTube provides lossy audio only. Not part of lossless fallback.';
|
||||
|
||||
@override
|
||||
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
|
||||
|
||||
@override
|
||||
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
|
||||
|
||||
@override
|
||||
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
|
||||
|
||||
@@ -4594,6 +4673,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
String get libraryAboutDescription =>
|
||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
||||
|
||||
@override
|
||||
String libraryTracksUnit(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String libraryLastScanned(String time) {
|
||||
return 'Last scanned: $time';
|
||||
@@ -4720,7 +4810,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -5037,6 +5127,258 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
return 'Album: $album';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@override
|
||||
String get collectionFoldersTitle => 'My folders';
|
||||
|
||||
@override
|
||||
String get collectionWishlist => 'Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionLoved => 'Loved';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
|
||||
@override
|
||||
String get collectionAddToPlaylist => 'Add to playlist';
|
||||
|
||||
@override
|
||||
String get collectionCreatePlaylist => 'Create playlist';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsYet => 'No playlists yet';
|
||||
|
||||
@override
|
||||
String get collectionNoPlaylistsSubtitle =>
|
||||
'Create a playlist to start categorizing tracks';
|
||||
|
||||
@override
|
||||
String collectionPlaylistTracks(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToPlaylist(String playlistName) {
|
||||
return 'Added to \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAlreadyInPlaylist(String playlistName) {
|
||||
return 'Already in \"$playlistName\"';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistCreated => 'Playlist created';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameHint => 'Playlist name';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistNameRequired => 'Playlist name is required';
|
||||
|
||||
@override
|
||||
String get collectionRenamePlaylist => 'Rename playlist';
|
||||
|
||||
@override
|
||||
String get collectionDeletePlaylist => 'Delete playlist';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistMessage(String playlistName) {
|
||||
return 'Delete \"$playlistName\" and all tracks inside it?';
|
||||
}
|
||||
|
||||
@override
|
||||
String get collectionPlaylistDeleted => 'Playlist deleted';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRenamed => 'Playlist renamed';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptyTitle => 'Wishlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionWishlistEmptySubtitle =>
|
||||
'Tap + on tracks to save what you want to download later';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptyTitle => 'Loved folder is empty';
|
||||
|
||||
@override
|
||||
String get collectionLovedEmptySubtitle =>
|
||||
'Tap love on tracks to keep your favorites';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptyTitle => 'Playlist is empty';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistEmptySubtitle =>
|
||||
'Long-press + on any track to add it here';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromPlaylist => 'Remove from playlist';
|
||||
|
||||
@override
|
||||
String get collectionRemoveFromFolder => 'Remove from folder';
|
||||
|
||||
@override
|
||||
String collectionRemoved(String trackName) {
|
||||
return '\"$trackName\" removed';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToLoved(String trackName) {
|
||||
return '\"$trackName\" added to Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromLoved(String trackName) {
|
||||
return '\"$trackName\" removed from Loved';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedToWishlist(String trackName) {
|
||||
return '\"$trackName\" added to Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionRemovedFromWishlist(String trackName) {
|
||||
return '\"$trackName\" removed from Wishlist';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackOptionAddToLoved => 'Add to Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromLoved => 'Remove from Loved';
|
||||
|
||||
@override
|
||||
String get trackOptionAddToWishlist => 'Add to Wishlist';
|
||||
|
||||
@override
|
||||
String get trackOptionRemoveFromWishlist => 'Remove from Wishlist';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistChangeCover => 'Change cover image';
|
||||
|
||||
@override
|
||||
String get collectionPlaylistRemoveCover => 'Remove cover image';
|
||||
|
||||
@override
|
||||
String selectionShareCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Share $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionConvertNoConvertible => 'No convertible tracks selected';
|
||||
|
||||
@override
|
||||
String get selectionBatchConvertConfirmTitle => 'Batch Convert';
|
||||
|
||||
@override
|
||||
String selectionBatchConvertConfirmMessage(
|
||||
int count,
|
||||
String format,
|
||||
String bitrate,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertProgress(int current, int total) {
|
||||
return 'Converting $current of $total...';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionBatchConvertSuccess(int success, int total, String format) {
|
||||
return 'Converted $success of $total tracks to $format';
|
||||
}
|
||||
|
||||
@override
|
||||
String downloadedAlbumDownloadedCount(int count) {
|
||||
return '$count baixado(s)';
|
||||
|
||||
@@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
|
||||
'Скачивайте треки Spotify в lossless качестве с Tidal и Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Альбомы';
|
||||
@@ -706,15 +706,15 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Треки не найдены';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
String get errorUrlNotRecognized => 'Ссылка не распознана';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
'Эта ссылка не поддерживается. Убедитесь, что URL-адрес указан правильно и установлено совместимое расширение.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
'Не удалось загрузить контент по этой ссылке. Пожалуйста, попробуйте еще раз.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -790,11 +790,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get folderOrganizationNone => 'Без организации';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
String get folderOrganizationByPlaylist => 'По плейлисту';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
'Отдельная папка для каждого плейлиста';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'По исполнителю';
|
||||
@@ -1414,6 +1414,38 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Фактическое качество зависит от доступности треков в сервисе';
|
||||
@@ -1450,7 +1482,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyEnabled =>
|
||||
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
|
||||
'Список исполнителей, чьи работы были удалены из названия папки (например, Джастин Бибер, Quavo → Джастин Бибер)';
|
||||
|
||||
@override
|
||||
String get downloadUsePrimaryArtistOnlyDisabled =>
|
||||
@@ -1940,7 +1972,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Скачайте FLAC с Tidal, Qobuz или Amazon Music';
|
||||
'Получите аудио в качестве FLAC от Tidal, Qobuz или Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2036,7 +2068,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String cleanupOrphanedDownloadsResult(int count) {
|
||||
return 'Removed $count orphaned entries from history';
|
||||
return 'Удалено $count утерянных записей из истории';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2061,7 +2093,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get cacheSectionStorage => 'Кэшированные данные';
|
||||
|
||||
@override
|
||||
String get cacheSectionMaintenance => 'Maintenance';
|
||||
String get cacheSectionMaintenance => 'Обслуживание';
|
||||
|
||||
@override
|
||||
String get cacheAppDirectory => 'Папка кэша приложения';
|
||||
@@ -2107,7 +2139,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedDesc =>
|
||||
'Remove orphaned download history and library entries for missing files.';
|
||||
'Удалить записи из истории загрузок и библиотеки, которые остались без файлов.';
|
||||
|
||||
@override
|
||||
String get cacheNoData => 'Нет кэшированных данных';
|
||||
@@ -2155,7 +2187,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get cacheCleanupUnusedSubtitle =>
|
||||
'Remove orphaned download history and missing library entries';
|
||||
'Удалить историю загрузок, оставшихся без просмотра, и отсутствующие записи в библиотеке';
|
||||
|
||||
@override
|
||||
String cacheCleanupResult(int downloadCount, int libraryCount) {
|
||||
@@ -2295,52 +2327,52 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackConvertFailed => 'Ошибка конвертации';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
String get cueSplitTitle => 'Разделить CUE Sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
String get cueSplitSubtitle => 'Разделить файл CUE+FLAC на отдельные треки';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
return 'Album: $album';
|
||||
return 'Альбом: $album';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
return 'Артист: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
return '$count треков';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
String get cueSplitConfirmTitle => 'Разделенный CUE-альбом';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
return 'Разбить \"$album\" на $count отдельных FLAC-файлов?';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
return 'Разделение CUE sheet... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
return 'Успешно разделено на $count треков';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
String get cueSplitFailed => 'Разделение CUE не удалось';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
String get cueSplitNoAudioFile => 'Аудиофайл для этого CUE sheet не найден';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
String get cueSplitButton => 'Разделить на Треки';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Создать';
|
||||
@@ -2506,7 +2538,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get selectionShareNoFiles => 'No shareable files found';
|
||||
String get selectionShareNoFiles =>
|
||||
'Файлы, доступные для совместного доступа, не найдены';
|
||||
|
||||
@override
|
||||
String selectionConvertCount(int count) {
|
||||
@@ -2539,7 +2572,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format at $bitrate?\n\nOriginal files will be deleted after conversion.';
|
||||
return 'Преобразовать $count $_temp0 в $format с $bitrate?';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2743,6 +2776,22 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
+184
-142
@@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albümler';
|
||||
@@ -664,11 +664,11 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get snackbarSelectExtFile => 'Lütfen .spotiflac-ext dosyasını seçin';
|
||||
|
||||
@override
|
||||
String get snackbarProviderPrioritySaved => 'Sağlayıcı önceliği kaydedildi';
|
||||
String get snackbarProviderPrioritySaved => 'Provider priority saved';
|
||||
|
||||
@override
|
||||
String get snackbarMetadataProviderSaved =>
|
||||
'Metadata sağlayıcı önceliği kaydedildi';
|
||||
'Metadata provider priority saved';
|
||||
|
||||
@override
|
||||
String snackbarExtensionInstalled(String extensionName) {
|
||||
@@ -869,21 +869,21 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get providerExtension => 'Eklenti';
|
||||
|
||||
@override
|
||||
String get metadataProviderPriorityTitle => 'Metadata Önceliği';
|
||||
String get metadataProviderPriorityTitle => 'Metadata Priority';
|
||||
|
||||
@override
|
||||
String get metadataProviderPriorityDescription =>
|
||||
'Metadata sağlayıcılarını sıralamak için kaydır. Uygulama şarkı ararken ve metadata alırken sağlayıcıları yukarıdan aşağıya doğru deneyecektir.';
|
||||
'Drag to reorder metadata providers. The app will try providers from top to bottom when searching for tracks and fetching metadata.';
|
||||
|
||||
@override
|
||||
String get metadataProviderPriorityInfo =>
|
||||
'Deezer\'ın istek sınırı yok ve birincil olarak önerilir. Spotify çok fazla istekten sonra sınırlama yapabilir.';
|
||||
'Deezer has no rate limits and is recommended as primary. Spotify may rate limit after many requests.';
|
||||
|
||||
@override
|
||||
String get metadataNoRateLimits => 'İstek sınırı yok';
|
||||
String get metadataNoRateLimits => 'No rate limits';
|
||||
|
||||
@override
|
||||
String get metadataMayRateLimit => 'Sınırlama yapabilir';
|
||||
String get metadataMayRateLimit => 'May rate limit';
|
||||
|
||||
@override
|
||||
String get logTitle => 'Kayıtlar';
|
||||
@@ -914,14 +914,13 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
'Tüm kayıtları temizlemek istediğinize emin misiniz?';
|
||||
|
||||
@override
|
||||
String get logFilterBySeverity => 'Kayıtları önem derecesine göre filtrele';
|
||||
String get logFilterBySeverity => 'Filter logs by severity';
|
||||
|
||||
@override
|
||||
String get logNoLogsYet => 'Henüz kayıt yok';
|
||||
String get logNoLogsYet => 'No logs yet';
|
||||
|
||||
@override
|
||||
String get logNoLogsYetSubtitle =>
|
||||
'Uygulamayı kullandıkça kayıtlar burada görünecek';
|
||||
String get logNoLogsYetSubtitle => 'Logs will appear here as you use the app';
|
||||
|
||||
@override
|
||||
String logEntriesFiltered(int count) {
|
||||
@@ -934,128 +933,125 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get credentialsTitle => 'Spotify Kimlik Bilgileri';
|
||||
String get credentialsTitle => 'Spotify Credentials';
|
||||
|
||||
@override
|
||||
String get credentialsDescription =>
|
||||
'Kendi Spotify uygulama kotanızı kullanmak için Client ID ve Secret girin.';
|
||||
'Enter your Client ID and Secret to use your own Spotify application quota.';
|
||||
|
||||
@override
|
||||
String get credentialsClientId => 'Client ID';
|
||||
|
||||
@override
|
||||
String get credentialsClientIdHint => 'Client ID yapıştır';
|
||||
String get credentialsClientIdHint => 'Paste Client ID';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecret => 'Client Secret';
|
||||
|
||||
@override
|
||||
String get credentialsClientSecretHint => 'Client Secret yapıştır';
|
||||
String get credentialsClientSecretHint => 'Paste Client Secret';
|
||||
|
||||
@override
|
||||
String get channelStable => 'Kararlı';
|
||||
String get channelStable => 'Stable';
|
||||
|
||||
@override
|
||||
String get channelPreview => 'Önizleme';
|
||||
String get channelPreview => 'Preview';
|
||||
|
||||
@override
|
||||
String get sectionSearchSource => 'Arama Kaynağı';
|
||||
String get sectionSearchSource => 'Search Source';
|
||||
|
||||
@override
|
||||
String get sectionDownload => 'İndirme';
|
||||
String get sectionDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get sectionPerformance => 'Performans';
|
||||
String get sectionPerformance => 'Performance';
|
||||
|
||||
@override
|
||||
String get sectionApp => 'Uygulama';
|
||||
String get sectionApp => 'App';
|
||||
|
||||
@override
|
||||
String get sectionData => 'Veri';
|
||||
String get sectionData => 'Data';
|
||||
|
||||
@override
|
||||
String get sectionDebug => 'Hata Ayıklama';
|
||||
String get sectionDebug => 'Debug';
|
||||
|
||||
@override
|
||||
String get sectionService => 'Hizmet';
|
||||
String get sectionService => 'Service';
|
||||
|
||||
@override
|
||||
String get sectionAudioQuality => 'Ses Kalitesi';
|
||||
String get sectionAudioQuality => 'Audio Quality';
|
||||
|
||||
@override
|
||||
String get sectionFileSettings => 'Dosya Ayarları';
|
||||
String get sectionFileSettings => 'File Settings';
|
||||
|
||||
@override
|
||||
String get sectionLyrics => 'Şarkı Sözleri';
|
||||
String get sectionLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get lyricsMode => 'Şarkı Sözü Modu';
|
||||
String get lyricsMode => 'Lyrics Mode';
|
||||
|
||||
@override
|
||||
String get lyricsModeDescription =>
|
||||
'Şarkı sözlerinin indirmelerle nasıl kaydedileceğini seçin';
|
||||
'Choose how lyrics are saved with your downloads';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbed => 'Dosyaya göm';
|
||||
String get lyricsModeEmbed => 'Embed in file';
|
||||
|
||||
@override
|
||||
String get lyricsModeEmbedSubtitle =>
|
||||
'Şarkı sözleri FLAC metadata içinde saklanır';
|
||||
String get lyricsModeEmbedSubtitle => 'Lyrics stored inside FLAC metadata';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternal => 'Harici .lrc dosyası';
|
||||
String get lyricsModeExternal => 'External .lrc file';
|
||||
|
||||
@override
|
||||
String get lyricsModeExternalSubtitle =>
|
||||
'Samsung Music gibi oynatıcılar için ayrı .lrc dosyası';
|
||||
'Separate .lrc file for players like Samsung Music';
|
||||
|
||||
@override
|
||||
String get lyricsModeBoth => 'Her ikisi';
|
||||
String get lyricsModeBoth => 'Both';
|
||||
|
||||
@override
|
||||
String get lyricsModeBothSubtitle => 'Göm ve .lrc dosyası kaydet';
|
||||
String get lyricsModeBothSubtitle => 'Embed and save .lrc file';
|
||||
|
||||
@override
|
||||
String get sectionColor => 'Renk';
|
||||
String get sectionColor => 'Color';
|
||||
|
||||
@override
|
||||
String get sectionTheme => 'Tema';
|
||||
String get sectionTheme => 'Theme';
|
||||
|
||||
@override
|
||||
String get sectionLayout => 'Düzen';
|
||||
String get sectionLayout => 'Layout';
|
||||
|
||||
@override
|
||||
String get sectionLanguage => 'Dil';
|
||||
String get sectionLanguage => 'Language';
|
||||
|
||||
@override
|
||||
String get appearanceLanguage => 'Uygulama Dili';
|
||||
String get appearanceLanguage => 'App Language';
|
||||
|
||||
@override
|
||||
String get settingsAppearanceSubtitle => 'Tema, renkler, görünüm';
|
||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Hizmet, kalite, dosya adı formatı';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, filename format';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle =>
|
||||
'Yedek, şarkı sözleri, kapak resmi, güncellemeler';
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'İndirme sağlayıcılarını yönet';
|
||||
String get settingsExtensionsSubtitle => 'Manage download providers';
|
||||
|
||||
@override
|
||||
String get settingsLogsSubtitle =>
|
||||
'Hata ayıklama için uygulama kayıtlarını görüntüle';
|
||||
String get settingsLogsSubtitle => 'View app logs for debugging';
|
||||
|
||||
@override
|
||||
String get loadingSharedLink => 'Paylaşılan bağlantı yükleniyor...';
|
||||
String get loadingSharedLink => 'Loading shared link...';
|
||||
|
||||
@override
|
||||
String get pressBackAgainToExit => 'Çıkmak için tekrar geri basın';
|
||||
String get pressBackAgainToExit => 'Press back again to exit';
|
||||
|
||||
@override
|
||||
String downloadAllCount(int count) {
|
||||
return 'Tümünü İndir ($count)';
|
||||
return 'Download All ($count)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1063,151 +1059,150 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count şarkı',
|
||||
one: '1 şarkı',
|
||||
other: '$count tracks',
|
||||
one: '1 track',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackCopyFilePath => 'Dosya yolunu kopyala';
|
||||
String get trackCopyFilePath => 'Copy file path';
|
||||
|
||||
@override
|
||||
String get trackRemoveFromDevice => 'Cihazdan kaldır';
|
||||
String get trackRemoveFromDevice => 'Remove from device';
|
||||
|
||||
@override
|
||||
String get trackLoadLyrics => 'Şarkı Sözlerini Yükle';
|
||||
String get trackLoadLyrics => 'Load Lyrics';
|
||||
|
||||
@override
|
||||
String get trackMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get trackFileInfo => 'Dosya Bilgisi';
|
||||
String get trackFileInfo => 'File Info';
|
||||
|
||||
@override
|
||||
String get trackLyrics => 'Şarkı Sözleri';
|
||||
String get trackLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get trackFileNotFound => 'Dosya bulunamadı';
|
||||
String get trackFileNotFound => 'File not found';
|
||||
|
||||
@override
|
||||
String get trackOpenInDeezer => 'Deezer\'da aç';
|
||||
String get trackOpenInDeezer => 'Open in Deezer';
|
||||
|
||||
@override
|
||||
String get trackOpenInSpotify => 'Spotify\'da aç';
|
||||
String get trackOpenInSpotify => 'Open in Spotify';
|
||||
|
||||
@override
|
||||
String get trackTrackName => 'Şarkı adı';
|
||||
String get trackTrackName => 'Track name';
|
||||
|
||||
@override
|
||||
String get trackArtist => 'Sanatçı';
|
||||
String get trackArtist => 'Artist';
|
||||
|
||||
@override
|
||||
String get trackAlbumArtist => 'Albüm sanatçısı';
|
||||
String get trackAlbumArtist => 'Album artist';
|
||||
|
||||
@override
|
||||
String get trackAlbum => 'Albüm';
|
||||
String get trackAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get trackTrackNumber => 'Şarkı numarası';
|
||||
String get trackTrackNumber => 'Track number';
|
||||
|
||||
@override
|
||||
String get trackDiscNumber => 'Disk numarası';
|
||||
String get trackDiscNumber => 'Disc number';
|
||||
|
||||
@override
|
||||
String get trackDuration => 'Süre';
|
||||
String get trackDuration => 'Duration';
|
||||
|
||||
@override
|
||||
String get trackAudioQuality => 'Ses kalitesi';
|
||||
String get trackAudioQuality => 'Audio quality';
|
||||
|
||||
@override
|
||||
String get trackReleaseDate => 'Yayın tarihi';
|
||||
String get trackReleaseDate => 'Release date';
|
||||
|
||||
@override
|
||||
String get trackGenre => 'Tür';
|
||||
String get trackGenre => 'Genre';
|
||||
|
||||
@override
|
||||
String get trackLabel => 'Plak şirketi';
|
||||
String get trackLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get trackCopyright => 'Telif hakkı';
|
||||
String get trackCopyright => 'Copyright';
|
||||
|
||||
@override
|
||||
String get trackDownloaded => 'İndirildi';
|
||||
String get trackDownloaded => 'Downloaded';
|
||||
|
||||
@override
|
||||
String get trackCopyLyrics => 'Şarkı sözlerini kopyala';
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Bu şarkı için şarkı sözü mevcut değil';
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@override
|
||||
String get trackLyricsTimeout =>
|
||||
'İstek zaman aşımına uğradı. Daha sonra tekrar deneyin.';
|
||||
String get trackLyricsTimeout => 'Request timed out. Try again later.';
|
||||
|
||||
@override
|
||||
String get trackLyricsLoadFailed => 'Şarkı sözleri yüklenemedi';
|
||||
String get trackLyricsLoadFailed => 'Failed to load lyrics';
|
||||
|
||||
@override
|
||||
String get trackEmbedLyrics => 'Şarkı Sözlerini Göm';
|
||||
String get trackEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbedded => 'Şarkı sözleri başarıyla gömüldü';
|
||||
String get trackLyricsEmbedded => 'Lyrics embedded successfully';
|
||||
|
||||
@override
|
||||
String get trackInstrumental => 'Enstrümantal şarkı';
|
||||
String get trackInstrumental => 'Instrumental track';
|
||||
|
||||
@override
|
||||
String get trackCopiedToClipboard => 'Panoya kopyalandı';
|
||||
String get trackCopiedToClipboard => 'Copied to clipboard';
|
||||
|
||||
@override
|
||||
String get trackDeleteConfirmTitle => 'Cihazdan kaldırılsın mı?';
|
||||
String get trackDeleteConfirmTitle => 'Remove from device?';
|
||||
|
||||
@override
|
||||
String get trackDeleteConfirmMessage =>
|
||||
'Bu işlem indirilen dosyayı kalıcı olarak silecek ve geçmişten kaldıracaktır.';
|
||||
'This will permanently delete the downloaded file and remove it from your history.';
|
||||
|
||||
@override
|
||||
String get dateToday => 'Bugün';
|
||||
String get dateToday => 'Today';
|
||||
|
||||
@override
|
||||
String get dateYesterday => 'Dün';
|
||||
String get dateYesterday => 'Yesterday';
|
||||
|
||||
@override
|
||||
String dateDaysAgo(int count) {
|
||||
return '$count gün önce';
|
||||
return '$count days ago';
|
||||
}
|
||||
|
||||
@override
|
||||
String dateWeeksAgo(int count) {
|
||||
return '$count hafta önce';
|
||||
return '$count weeks ago';
|
||||
}
|
||||
|
||||
@override
|
||||
String dateMonthsAgo(int count) {
|
||||
return '$count ay önce';
|
||||
return '$count months ago';
|
||||
}
|
||||
|
||||
@override
|
||||
String get storeFilterAll => 'Tümü';
|
||||
String get storeFilterAll => 'All';
|
||||
|
||||
@override
|
||||
String get storeFilterMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeFilterDownload => 'İndirme';
|
||||
String get storeFilterDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeFilterUtility => 'Araç';
|
||||
String get storeFilterUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeFilterLyrics => 'Şarkı Sözleri';
|
||||
String get storeFilterLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeFilterIntegration => 'Entegrasyon';
|
||||
String get storeFilterIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get storeClearFilters => 'Filtreleri temizle';
|
||||
String get storeClearFilters => 'Clear filters';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
@@ -1251,137 +1246,136 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Varsayılan (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Dahili aramayı kullan';
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Yazar';
|
||||
String get extensionAuthor => 'Author';
|
||||
|
||||
@override
|
||||
String get extensionId => 'ID';
|
||||
|
||||
@override
|
||||
String get extensionError => 'Hata';
|
||||
String get extensionError => 'Error';
|
||||
|
||||
@override
|
||||
String get extensionCapabilities => 'Yetenekler';
|
||||
String get extensionCapabilities => 'Capabilities';
|
||||
|
||||
@override
|
||||
String get extensionMetadataProvider => 'Metadata Sağlayıcı';
|
||||
String get extensionMetadataProvider => 'Metadata Provider';
|
||||
|
||||
@override
|
||||
String get extensionDownloadProvider => 'İndirme Sağlayıcı';
|
||||
String get extensionDownloadProvider => 'Download Provider';
|
||||
|
||||
@override
|
||||
String get extensionLyricsProvider => 'Şarkı Sözü Sağlayıcı';
|
||||
String get extensionLyricsProvider => 'Lyrics Provider';
|
||||
|
||||
@override
|
||||
String get extensionUrlHandler => 'URL İşleyici';
|
||||
String get extensionUrlHandler => 'URL Handler';
|
||||
|
||||
@override
|
||||
String get extensionQualityOptions => 'Kalite Seçenekleri';
|
||||
String get extensionQualityOptions => 'Quality Options';
|
||||
|
||||
@override
|
||||
String get extensionPostProcessingHooks => 'İşlem Sonrası Kancalar';
|
||||
String get extensionPostProcessingHooks => 'Post-Processing Hooks';
|
||||
|
||||
@override
|
||||
String get extensionPermissions => 'İzinler';
|
||||
String get extensionPermissions => 'Permissions';
|
||||
|
||||
@override
|
||||
String get extensionSettings => 'Ayarlar';
|
||||
String get extensionSettings => 'Settings';
|
||||
|
||||
@override
|
||||
String get extensionRemoveButton => 'Eklentiyi Kaldır';
|
||||
String get extensionRemoveButton => 'Remove Extension';
|
||||
|
||||
@override
|
||||
String get extensionUpdated => 'Güncellendi';
|
||||
String get extensionUpdated => 'Updated';
|
||||
|
||||
@override
|
||||
String get extensionMinAppVersion => 'Min Uygulama Sürümü';
|
||||
String get extensionMinAppVersion => 'Min App Version';
|
||||
|
||||
@override
|
||||
String get extensionCustomTrackMatching => 'Özel Şarkı Eşleştirme';
|
||||
String get extensionCustomTrackMatching => 'Custom Track Matching';
|
||||
|
||||
@override
|
||||
String get extensionPostProcessing => 'İşlem Sonrası';
|
||||
String get extensionPostProcessing => 'Post-Processing';
|
||||
|
||||
@override
|
||||
String extensionHooksAvailable(int count) {
|
||||
return '$count kanca mevcut';
|
||||
return '$count hook(s) available';
|
||||
}
|
||||
|
||||
@override
|
||||
String extensionPatternsCount(int count) {
|
||||
return '$count desen';
|
||||
return '$count pattern(s)';
|
||||
}
|
||||
|
||||
@override
|
||||
String extensionStrategy(String strategy) {
|
||||
return 'Strateji: $strategy';
|
||||
return 'Strategy: $strategy';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionsProviderPrioritySection => 'Sağlayıcı Önceliği';
|
||||
String get extensionsProviderPrioritySection => 'Provider Priority';
|
||||
|
||||
@override
|
||||
String get extensionsInstalledSection => 'Yüklü Eklentiler';
|
||||
String get extensionsInstalledSection => 'Installed Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsNoExtensions => 'Yüklü eklenti yok';
|
||||
String get extensionsNoExtensions => 'No extensions installed';
|
||||
|
||||
@override
|
||||
String get extensionsNoExtensionsSubtitle =>
|
||||
'Yeni sağlayıcılar eklemek için .spotiflac-ext dosyalarını yükleyin';
|
||||
'Install .spotiflac-ext files to add new providers';
|
||||
|
||||
@override
|
||||
String get extensionsInstallButton => 'Eklenti Yükle';
|
||||
String get extensionsInstallButton => 'Install Extension';
|
||||
|
||||
@override
|
||||
String get extensionsInfoTip =>
|
||||
'Eklentiler yeni metadata ve indirme sağlayıcıları ekleyebilir. Sadece güvenilir kaynaklardan eklenti yükleyin.';
|
||||
'Extensions can add new metadata and download providers. Only install extensions from trusted sources.';
|
||||
|
||||
@override
|
||||
String get extensionsInstalledSuccess => 'Eklenti başarıyla yüklendi';
|
||||
String get extensionsInstalledSuccess => 'Extension installed successfully';
|
||||
|
||||
@override
|
||||
String get extensionsDownloadPriority => 'İndirme Önceliği';
|
||||
String get extensionsDownloadPriority => 'Download Priority';
|
||||
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle =>
|
||||
'İndirme hizmeti sırasını ayarla';
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'İndirme sağlayıcısı olan eklenti yok';
|
||||
'No extensions with download provider';
|
||||
|
||||
@override
|
||||
String get extensionsMetadataPriority => 'Metadata Önceliği';
|
||||
String get extensionsMetadataPriority => 'Metadata Priority';
|
||||
|
||||
@override
|
||||
String get extensionsMetadataPrioritySubtitle =>
|
||||
'Arama ve metadata kaynağı sırasını ayarla';
|
||||
'Set search & metadata source order';
|
||||
|
||||
@override
|
||||
String get extensionsNoMetadataProvider =>
|
||||
'Metadata sağlayıcısı olan eklenti yok';
|
||||
'No extensions with metadata provider';
|
||||
|
||||
@override
|
||||
String get extensionsSearchProvider => 'Arama Sağlayıcı';
|
||||
String get extensionsSearchProvider => 'Search Provider';
|
||||
|
||||
@override
|
||||
String get extensionsNoCustomSearch => 'Özel arama olan eklenti yok';
|
||||
String get extensionsNoCustomSearch => 'No extensions with custom search';
|
||||
|
||||
@override
|
||||
String get extensionsSearchProviderDescription =>
|
||||
'Şarkı aramak için hangi hizmetin kullanılacağını seçin';
|
||||
'Choose which service to use for searching tracks';
|
||||
|
||||
@override
|
||||
String get extensionsCustomSearch => 'Özel arama';
|
||||
String get extensionsCustomSearch => 'Custom search';
|
||||
|
||||
@override
|
||||
String get extensionsErrorLoading => 'Eklenti yüklenirken hata oluştu';
|
||||
String get extensionsErrorLoading => 'Error loading extension';
|
||||
|
||||
@override
|
||||
String get qualityFlacLossless => 'FLAC Lossless';
|
||||
@@ -1401,6 +1395,38 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -1631,19 +1657,19 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String discographyAddedToQueue(int count) {
|
||||
return '$count şarkı kuyruğa eklendi';
|
||||
return 'Added $count tracks to queue';
|
||||
}
|
||||
|
||||
@override
|
||||
String discographySkippedDownloaded(int added, int skipped) {
|
||||
return '$added eklendi, $skipped zaten indirilmiş';
|
||||
return '$added added, $skipped already downloaded';
|
||||
}
|
||||
|
||||
@override
|
||||
String get discographyNoAlbums => 'Albüm mevcut değil';
|
||||
String get discographyNoAlbums => 'No albums available';
|
||||
|
||||
@override
|
||||
String get discographyFailedToFetch => 'Bazı albümler alınamadı';
|
||||
String get discographyFailedToFetch => 'Failed to fetch some albums';
|
||||
|
||||
@override
|
||||
String get sectionStorageAccess => 'Storage Access';
|
||||
@@ -1902,7 +1928,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2696,6 +2722,22 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
|
||||
+280
-113
@@ -1389,6 +1389,38 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
'Actual quality depends on track availability from the service';
|
||||
@@ -2685,6 +2717,22 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -2925,294 +2973,283 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
String get appName => 'SpotiFLAC';
|
||||
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
String get navHome => '主页';
|
||||
|
||||
@override
|
||||
String get navLibrary => 'Library';
|
||||
String get navLibrary => '乐库';
|
||||
|
||||
@override
|
||||
String get navSettings => 'Settings';
|
||||
String get navSettings => '设置';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
String get navStore => '商店';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Home';
|
||||
String get homeTitle => '主页';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
||||
String get homeSubtitle => '粘贴 Spotify 链接或按名称搜索';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
String get homeSupports => '支持:歌曲、专辑、播放列表、艺人网址';
|
||||
|
||||
@override
|
||||
String get homeRecent => 'Recent';
|
||||
String get homeRecent => '最近';
|
||||
|
||||
@override
|
||||
String get historyFilterAll => 'All';
|
||||
String get historyFilterAll => '全部';
|
||||
|
||||
@override
|
||||
String get historyFilterAlbums => 'Albums';
|
||||
String get historyFilterAlbums => '专辑';
|
||||
|
||||
@override
|
||||
String get historyFilterSingles => 'Singles';
|
||||
String get historyFilterSingles => '单曲';
|
||||
|
||||
@override
|
||||
String get historySearchHint => 'Search history...';
|
||||
String get historySearchHint => '搜索历史……';
|
||||
|
||||
@override
|
||||
String get settingsTitle => 'Settings';
|
||||
String get settingsTitle => '设置';
|
||||
|
||||
@override
|
||||
String get settingsDownload => 'Download';
|
||||
String get settingsDownload => '下载';
|
||||
|
||||
@override
|
||||
String get settingsAppearance => 'Appearance';
|
||||
String get settingsAppearance => '外观';
|
||||
|
||||
@override
|
||||
String get settingsOptions => 'Options';
|
||||
String get settingsOptions => '选项';
|
||||
|
||||
@override
|
||||
String get settingsExtensions => 'Extensions';
|
||||
String get settingsExtensions => '扩展';
|
||||
|
||||
@override
|
||||
String get settingsAbout => 'About';
|
||||
String get settingsAbout => '关于';
|
||||
|
||||
@override
|
||||
String get downloadTitle => 'Download';
|
||||
String get downloadTitle => '下载';
|
||||
|
||||
@override
|
||||
String get downloadAskQualitySubtitle =>
|
||||
'Show quality picker for each download';
|
||||
String get downloadAskQualitySubtitle => '为每次下载显示质量选择器';
|
||||
|
||||
@override
|
||||
String get downloadFilenameFormat => 'Filename Format';
|
||||
String get downloadFilenameFormat => '文件名格式';
|
||||
|
||||
@override
|
||||
String get downloadFolderOrganization => 'Folder Organization';
|
||||
String get downloadFolderOrganization => '文件夹结构';
|
||||
|
||||
@override
|
||||
String get appearanceTitle => 'Appearance';
|
||||
String get appearanceTitle => '外观';
|
||||
|
||||
@override
|
||||
String get appearanceThemeSystem => 'System';
|
||||
String get appearanceThemeSystem => '系统';
|
||||
|
||||
@override
|
||||
String get appearanceThemeLight => 'Light';
|
||||
String get appearanceThemeLight => '浅色';
|
||||
|
||||
@override
|
||||
String get appearanceThemeDark => 'Dark';
|
||||
String get appearanceThemeDark => '深色';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColor => 'Dynamic Color';
|
||||
String get appearanceDynamicColor => '动态色彩';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColorSubtitle => 'Use colors from your wallpaper';
|
||||
String get appearanceDynamicColorSubtitle => '使用壁纸的颜色';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryView => 'History View';
|
||||
String get appearanceHistoryView => '历史记录';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryViewList => 'List';
|
||||
String get appearanceHistoryViewList => '列表';
|
||||
|
||||
@override
|
||||
String get appearanceHistoryViewGrid => 'Grid';
|
||||
String get appearanceHistoryViewGrid => '网格';
|
||||
|
||||
@override
|
||||
String get optionsTitle => 'Options';
|
||||
String get optionsTitle => '选项';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProvider => 'Primary Provider';
|
||||
String get optionsPrimaryProvider => '主要提供者';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
String get optionsPrimaryProviderSubtitle => '按歌曲名称搜索时使用的服务。';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
return 'Using extension: $extensionName';
|
||||
return '使用扩展:$extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
String get optionsSwitchBack => '点击 Deezer 或 Spotify 即可从扩展程序切换回来';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
String get optionsAutoFallback => '自动回退';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallbackSubtitle =>
|
||||
'Try other services if download fails';
|
||||
String get optionsAutoFallbackSubtitle => '如果下载失败,请尝试其他服务';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
String get optionsUseExtensionProviders => '使用扩展提供商';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn => '扩展会被最先尝试';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
String get optionsUseExtensionProvidersOff => '仅使用内置提供商';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||
String get optionsEmbedLyrics => '内嵌歌词';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Embed synced lyrics into FLAC files';
|
||||
String get optionsEmbedLyricsSubtitle => '嵌入已同步歌词到 FLAC 文件';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Max Quality Cover';
|
||||
String get optionsMaxQualityCover => '最高质量封面';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
String get optionsMaxQualityCoverSubtitle => '下载最高分辨率封面';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
||||
String get optionsConcurrentDownloads => '并行下载数';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
String get optionsConcurrentSequential => '按顺序下载(一次一首)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count parallel downloads';
|
||||
return '同时下载 $count 首';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
String get optionsConcurrentWarning => '并行下载可能会触发速率限制';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Store';
|
||||
String get optionsExtensionStore => '扩展商店';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
||||
String get optionsExtensionStoreSubtitle => '在导航中显示商店标签';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => 'Check for Updates';
|
||||
String get optionsCheckUpdates => '检查更新';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdatesSubtitle =>
|
||||
'Notify when new version is available';
|
||||
String get optionsCheckUpdatesSubtitle => '当有新版本可用时通知';
|
||||
|
||||
@override
|
||||
String get optionsUpdateChannel => 'Update Channel';
|
||||
String get optionsUpdateChannel => '更新频道';
|
||||
|
||||
@override
|
||||
String get optionsUpdateChannelStable => 'Stable releases only';
|
||||
String get optionsUpdateChannelStable => '仅稳定版本';
|
||||
|
||||
@override
|
||||
String get optionsUpdateChannelPreview => 'Get preview releases';
|
||||
String get optionsUpdateChannelPreview => '获取预览版本';
|
||||
|
||||
@override
|
||||
String get optionsUpdateChannelWarning =>
|
||||
'Preview may contain bugs or incomplete features';
|
||||
String get optionsUpdateChannelWarning => '预览版本可能包含错误或者尚未完善的功能';
|
||||
|
||||
@override
|
||||
String get optionsClearHistory => 'Clear Download History';
|
||||
String get optionsClearHistory => '清除下载历史记录';
|
||||
|
||||
@override
|
||||
String get optionsClearHistorySubtitle =>
|
||||
'Remove all downloaded tracks from history';
|
||||
String get optionsClearHistorySubtitle => '从历史记录中清除所有已下载的曲目';
|
||||
|
||||
@override
|
||||
String get optionsDetailedLogging => 'Detailed Logging';
|
||||
String get optionsDetailedLogging => '详细日志';
|
||||
|
||||
@override
|
||||
String get optionsDetailedLoggingOn => 'Detailed logs are being recorded';
|
||||
String get optionsDetailedLoggingOn => '正在记录详细日志';
|
||||
|
||||
@override
|
||||
String get optionsDetailedLoggingOff => 'Enable for bug reports';
|
||||
String get optionsDetailedLoggingOff => '为错误报告启用';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyCredentials => 'Spotify Credentials';
|
||||
String get optionsSpotifyCredentials => 'Spotify 凭据';
|
||||
|
||||
@override
|
||||
String optionsSpotifyCredentialsConfigured(String clientId) {
|
||||
return 'Client ID: $clientId...';
|
||||
return '客户端 ID:$clientId……';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsSpotifyCredentialsRequired => 'Required - tap to configure';
|
||||
String get optionsSpotifyCredentialsRequired => '必填 - 点击配置';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyWarning =>
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
|
||||
'Spotify 需要您自己的 API 凭据。在 developer.spotify.com 免费获取';
|
||||
|
||||
@override
|
||||
String get optionsSpotifyDeprecationWarning =>
|
||||
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
|
||||
'Spotify 搜索将在 2026 年 3 月 3 日因 Spotify API 更改而被废弃。请切换到 Deezer。';
|
||||
|
||||
@override
|
||||
String get extensionsTitle => 'Extensions';
|
||||
String get extensionsTitle => '扩展';
|
||||
|
||||
@override
|
||||
String get extensionsDisabled => 'Disabled';
|
||||
String get extensionsDisabled => '禁用';
|
||||
|
||||
@override
|
||||
String extensionsVersion(String version) {
|
||||
return 'Version $version';
|
||||
return '版本 $version';
|
||||
}
|
||||
|
||||
@override
|
||||
String extensionsAuthor(String author) {
|
||||
return 'by $author';
|
||||
return '来自 $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionsUninstall => 'Uninstall';
|
||||
String get extensionsUninstall => '卸载';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Extension Store';
|
||||
String get storeTitle => '扩展商店';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Search extensions...';
|
||||
String get storeSearch => '搜索扩展……';
|
||||
|
||||
@override
|
||||
String get storeInstall => 'Install';
|
||||
String get storeInstall => '安装';
|
||||
|
||||
@override
|
||||
String get storeInstalled => 'Installed';
|
||||
String get storeInstalled => '已安装';
|
||||
|
||||
@override
|
||||
String get storeUpdate => 'Update';
|
||||
String get storeUpdate => '更新';
|
||||
|
||||
@override
|
||||
String get aboutTitle => 'About';
|
||||
String get aboutTitle => '关于';
|
||||
|
||||
@override
|
||||
String get aboutContributors => 'Contributors';
|
||||
String get aboutContributors => '贡献者';
|
||||
|
||||
@override
|
||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
||||
String get aboutMobileDeveloper => '移动版本开发者';
|
||||
|
||||
@override
|
||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||
String get aboutOriginalCreator => '原 SpotiLDAC 创建者';
|
||||
|
||||
@override
|
||||
String get aboutLogoArtist =>
|
||||
'The talented artist who created our beautiful app logo!';
|
||||
String get aboutLogoArtist => '有才华的艺术家创建了我们美丽的应用图标!';
|
||||
|
||||
@override
|
||||
String get aboutTranslators => 'Translators';
|
||||
String get aboutTranslators => '译者';
|
||||
|
||||
@override
|
||||
String get aboutSpecialThanks => 'Special Thanks';
|
||||
String get aboutSpecialThanks => '特别鸣谢';
|
||||
|
||||
@override
|
||||
String get aboutLinks => 'Links';
|
||||
String get aboutLinks => '相关链接';
|
||||
|
||||
@override
|
||||
String get aboutMobileSource => 'Mobile source code';
|
||||
String get aboutMobileSource => '移动版本源代码';
|
||||
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
String get aboutPCSource => '桌面版本源代码';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
String get aboutReportIssue => '报告一个问题';
|
||||
|
||||
@override
|
||||
String get aboutReportIssueSubtitle => 'Report any problems you encounter';
|
||||
String get aboutReportIssueSubtitle => '报告您遇到的任何问题';
|
||||
|
||||
@override
|
||||
String get aboutFeatureRequest => 'Feature request';
|
||||
@@ -3269,7 +3306,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -3385,20 +3422,19 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
String get setupNotificationGranted => 'Notification Permission Granted!';
|
||||
|
||||
@override
|
||||
String get setupNotificationEnable => 'Enable Notifications';
|
||||
String get setupNotificationEnable => '启用通知';
|
||||
|
||||
@override
|
||||
String get setupFolderChoose => 'Choose Download Folder';
|
||||
String get setupFolderChoose => '选择下载文件夹';
|
||||
|
||||
@override
|
||||
String get setupFolderDescription =>
|
||||
'Select a folder where your downloaded music will be saved.';
|
||||
String get setupFolderDescription => '选择保存您下载的音乐的文件夹。';
|
||||
|
||||
@override
|
||||
String get setupSelectFolder => 'Select Folder';
|
||||
String get setupSelectFolder => '选择文件夹';
|
||||
|
||||
@override
|
||||
String get setupEnableNotifications => 'Enable Notifications';
|
||||
String get setupEnableNotifications => '启用通知';
|
||||
|
||||
@override
|
||||
String get setupNotificationBackgroundDescription =>
|
||||
@@ -3595,11 +3631,21 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
String get errorRateLimited => 'Rate Limited';
|
||||
|
||||
@override
|
||||
String get errorRateLimitedMessage =>
|
||||
'Too many requests. Please wait a moment before searching again.';
|
||||
String get errorRateLimitedMessage => '请求过多。请等一会再搜索。';
|
||||
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
String get errorNoTracksFound => '未找到曲目';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -3674,6 +3720,13 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
@override
|
||||
String get folderOrganizationNone => 'No organization';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'By Artist';
|
||||
|
||||
@@ -4722,7 +4775,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -5039,6 +5092,54 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
return 'Album: $album';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
@@ -5609,7 +5710,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -5941,6 +6042,17 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'This link is not supported. Make sure the URL is correct and a compatible extension is installed.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Failed to load content from this link. Please try again.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
return 'Cannot load $item: missing extension source';
|
||||
@@ -6014,6 +6126,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
@override
|
||||
String get folderOrganizationNone => 'No organization';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylist => 'By Playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByPlaylistSubtitle =>
|
||||
'Separate folder for each playlist';
|
||||
|
||||
@override
|
||||
String get folderOrganizationByArtist => 'By Artist';
|
||||
|
||||
@@ -7062,7 +7181,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -7379,6 +7498,54 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
|
||||
@override
|
||||
String get trackConvertFailed => 'Conversion failed';
|
||||
|
||||
@override
|
||||
String get cueSplitTitle => 'Split CUE Sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitSubtitle => 'Split CUE+FLAC into individual tracks';
|
||||
|
||||
@override
|
||||
String cueSplitAlbum(String album) {
|
||||
return 'Album: $album';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitArtist(String artist) {
|
||||
return 'Artist: $artist';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitTrackCount(int count) {
|
||||
return '$count tracks';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitConfirmTitle => 'Split CUE Album';
|
||||
|
||||
@override
|
||||
String cueSplitConfirmMessage(String album, int count) {
|
||||
return 'Split \"$album\" into $count individual FLAC files?\n\nFiles will be saved to the same directory.';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSplitting(int current, int total) {
|
||||
return 'Splitting CUE sheet... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String cueSplitSuccess(int count) {
|
||||
return 'Split into $count tracks successfully';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cueSplitFailed => 'CUE split failed';
|
||||
|
||||
@override
|
||||
String get cueSplitNoAudioFile => 'Audio file not found for this CUE sheet';
|
||||
|
||||
@override
|
||||
String get cueSplitButton => 'Split into Tracks';
|
||||
|
||||
@override
|
||||
String get actionCreate => 'Create';
|
||||
|
||||
|
||||
+1479
-71
File diff suppressed because it is too large
Load Diff
+717
-24
@@ -17,7 +17,7 @@
|
||||
"@navSettings": {
|
||||
"description": "Bottom navigation - Settings tab"
|
||||
},
|
||||
"navStore": "Store",
|
||||
"navStore": "Repo",
|
||||
"@navStore": {
|
||||
"description": "Bottom navigation - Extension store tab"
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"@homeTitle": {
|
||||
"description": "Home screen title"
|
||||
},
|
||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
||||
"homeSubtitle": "Paste a supported URL or search by name",
|
||||
"@homeSubtitle": {
|
||||
"description": "Subtitle shown below search box"
|
||||
},
|
||||
@@ -89,6 +89,14 @@
|
||||
"@downloadFilenameFormat": {
|
||||
"description": "Setting for output filename pattern"
|
||||
},
|
||||
"downloadSingleFilenameFormat": "Single Filename Format",
|
||||
"@downloadSingleFilenameFormat": {
|
||||
"description": "Setting for output filename pattern for singles/EPs"
|
||||
},
|
||||
"downloadSingleFilenameFormatDescription": "Filename pattern for singles and EPs. Uses the same tags as the album format.",
|
||||
"@downloadSingleFilenameFormatDescription": {
|
||||
"description": "Subtitle description for single filename format setting"
|
||||
},
|
||||
"downloadFolderOrganization": "Folder Organization",
|
||||
"@downloadFolderOrganization": {
|
||||
"description": "Setting for folder structure"
|
||||
@@ -150,6 +158,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsDefaultSearchTab": "Default Search Tab",
|
||||
"@optionsDefaultSearchTab": {
|
||||
"description": "Title for the preferred default search tab setting"
|
||||
},
|
||||
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
|
||||
"@optionsDefaultSearchTabSubtitle": {
|
||||
"description": "Subtitle for the preferred default search tab setting"
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
@@ -190,6 +206,42 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsReplayGain": "ReplayGain",
|
||||
"@optionsReplayGain": {
|
||||
"description": "Title for ReplayGain setting toggle"
|
||||
},
|
||||
"optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)",
|
||||
"@optionsReplayGainSubtitleOn": {
|
||||
"description": "Subtitle when ReplayGain is enabled"
|
||||
},
|
||||
"optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags",
|
||||
"@optionsReplayGainSubtitleOff": {
|
||||
"description": "Subtitle when ReplayGain is disabled"
|
||||
},
|
||||
"optionsArtistTagMode": "Artist Tag Mode",
|
||||
"@optionsArtistTagMode": {
|
||||
"description": "Setting title for how artist metadata is written into files"
|
||||
},
|
||||
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
|
||||
"@optionsArtistTagModeDescription": {
|
||||
"description": "Bottom-sheet description for artist tag mode setting"
|
||||
},
|
||||
"optionsArtistTagModeJoined": "Single joined value",
|
||||
"@optionsArtistTagModeJoined": {
|
||||
"description": "Artist tag mode option that joins multiple artists into one value"
|
||||
},
|
||||
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
|
||||
"@optionsArtistTagModeJoinedSubtitle": {
|
||||
"description": "Subtitle for joined artist tag mode"
|
||||
},
|
||||
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
|
||||
"@optionsArtistTagModeSplitVorbis": {
|
||||
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
|
||||
},
|
||||
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
|
||||
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||
"description": "Subtitle for split Vorbis artist tag mode"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
@@ -211,11 +263,11 @@
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Store",
|
||||
"optionsExtensionStore": "Extension Repo",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
},
|
||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
||||
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
|
||||
"@optionsExtensionStoreSubtitle": {
|
||||
"description": "Subtitle for extension store toggle"
|
||||
},
|
||||
@@ -318,7 +370,7 @@
|
||||
"@extensionsUninstall": {
|
||||
"description": "Uninstall extension button"
|
||||
},
|
||||
"storeTitle": "Extension Store",
|
||||
"storeTitle": "Extension Repo",
|
||||
"@storeTitle": {
|
||||
"description": "Store screen title"
|
||||
},
|
||||
@@ -378,6 +430,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
@@ -999,6 +1055,46 @@
|
||||
"@searchPlaylists": {
|
||||
"description": "Search result category - playlists"
|
||||
},
|
||||
"searchSortTitle": "Sort Results",
|
||||
"@searchSortTitle": {
|
||||
"description": "Bottom sheet title for search sort options"
|
||||
},
|
||||
"searchSortDefault": "Default",
|
||||
"@searchSortDefault": {
|
||||
"description": "Sort option - default API order"
|
||||
},
|
||||
"searchSortTitleAZ": "Title (A-Z)",
|
||||
"@searchSortTitleAZ": {
|
||||
"description": "Sort option - title ascending"
|
||||
},
|
||||
"searchSortTitleZA": "Title (Z-A)",
|
||||
"@searchSortTitleZA": {
|
||||
"description": "Sort option - title descending"
|
||||
},
|
||||
"searchSortArtistAZ": "Artist (A-Z)",
|
||||
"@searchSortArtistAZ": {
|
||||
"description": "Sort option - artist ascending"
|
||||
},
|
||||
"searchSortArtistZA": "Artist (Z-A)",
|
||||
"@searchSortArtistZA": {
|
||||
"description": "Sort option - artist descending"
|
||||
},
|
||||
"searchSortDurationShort": "Duration (Shortest)",
|
||||
"@searchSortDurationShort": {
|
||||
"description": "Sort option - shortest duration first"
|
||||
},
|
||||
"searchSortDurationLong": "Duration (Longest)",
|
||||
"@searchSortDurationLong": {
|
||||
"description": "Sort option - longest duration first"
|
||||
},
|
||||
"searchSortDateOldest": "Release Date (Oldest)",
|
||||
"@searchSortDateOldest": {
|
||||
"description": "Sort option - oldest release first"
|
||||
},
|
||||
"searchSortDateNewest": "Release Date (Newest)",
|
||||
"@searchSortDateNewest": {
|
||||
"description": "Sort option - newest release first"
|
||||
},
|
||||
"tooltipPlay": "Play",
|
||||
"@tooltipPlay": {
|
||||
"description": "Tooltip - play button"
|
||||
@@ -1119,6 +1215,18 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
|
||||
"@providerPriorityFallbackExtensionsHint": {
|
||||
"description": "Hint below the extension fallback selection list"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
@@ -1479,6 +1587,14 @@
|
||||
"@trackLyricsNotAvailable": {
|
||||
"description": "Message when lyrics not found"
|
||||
},
|
||||
"trackLyricsNotInFile": "No lyrics found in this file",
|
||||
"@trackLyricsNotInFile": {
|
||||
"description": "Message when no embedded lyrics in audio file"
|
||||
},
|
||||
"trackFetchOnlineLyrics": "Fetch from Online",
|
||||
"@trackFetchOnlineLyrics": {
|
||||
"description": "Action - fetch lyrics from online providers"
|
||||
},
|
||||
"trackLyricsTimeout": "Request timed out. Try again later.",
|
||||
"@trackLyricsTimeout": {
|
||||
"description": "Message when lyrics request times out"
|
||||
@@ -1614,7 +1730,7 @@
|
||||
"@storeNewRepoUrlLabel": {
|
||||
"description": "Label for the new repository URL field inside the dialog"
|
||||
},
|
||||
"storeLoadError": "Failed to load store",
|
||||
"storeLoadError": "Failed to load repository",
|
||||
"@storeLoadError": {
|
||||
"description": "Error heading when the store cannot be loaded"
|
||||
},
|
||||
@@ -1626,7 +1742,7 @@
|
||||
"@storeEmptyNoResults": {
|
||||
"description": "Message when search/filter returns no results"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default (Deezer)",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
@@ -1765,6 +1881,14 @@
|
||||
"@extensionsDownloadPrioritySubtitle": {
|
||||
"description": "Subtitle for download priority"
|
||||
},
|
||||
"extensionsFallbackTitle": "Fallback Extensions",
|
||||
"@extensionsFallbackTitle": {
|
||||
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||
},
|
||||
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
|
||||
"@extensionsFallbackSubtitle": {
|
||||
"description": "Subtitle for download fallback extensions menu"
|
||||
},
|
||||
"extensionsNoDownloadProvider": "No extensions with download provider",
|
||||
"@extensionsNoDownloadProvider": {
|
||||
"description": "Empty state - no download providers"
|
||||
@@ -1825,22 +1949,50 @@
|
||||
"@qualityHiResFlacMaxSubtitle": {
|
||||
"description": "Technical spec for hi-res max"
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@downloadLossyFormat": {
|
||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
||||
},
|
||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||
"@downloadLossy320Format": {
|
||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
||||
},
|
||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
||||
"@downloadLossy320FormatDesc": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
},
|
||||
"qualityNote": "Actual quality depends on track availability from the service",
|
||||
"@qualityNote": {
|
||||
"description": "Note about quality availability"
|
||||
},
|
||||
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Ask Before Download",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -1961,6 +2113,14 @@
|
||||
"@albumFolderArtistAlbumSinglesSubtitle": {
|
||||
"description": "Folder structure example"
|
||||
},
|
||||
"albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)",
|
||||
"@albumFolderArtistAlbumFlat": {
|
||||
"description": "Album folder option with singles directly in artist folder"
|
||||
},
|
||||
"albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac",
|
||||
"@albumFolderArtistAlbumFlatSubtitle": {
|
||||
"description": "Folder structure example for flat singles"
|
||||
},
|
||||
"downloadedAlbumDeleteSelected": "Delete Selected",
|
||||
"@downloadedAlbumDeleteSelected": {
|
||||
"description": "Button - delete selected tracks"
|
||||
@@ -2323,6 +2483,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
|
||||
"@libraryFilesUnit": {
|
||||
"description": "Unit label for files count during library scanning",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2340,6 +2509,10 @@
|
||||
"@libraryScanning": {
|
||||
"description": "Status during scan"
|
||||
},
|
||||
"libraryScanFinalizing": "Finalizing library...",
|
||||
"@libraryScanFinalizing": {
|
||||
"description": "Status shown after file scanning finishes but library persistence is still running"
|
||||
},
|
||||
"libraryScanProgress": "{progress}% of {total} files",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
@@ -2437,6 +2610,30 @@
|
||||
"@libraryFilterFormat": {
|
||||
"description": "Filter section - file format"
|
||||
},
|
||||
"libraryFilterMetadata": "Metadata",
|
||||
"@libraryFilterMetadata": {
|
||||
"description": "Filter section - metadata completeness"
|
||||
},
|
||||
"libraryFilterMetadataComplete": "Complete metadata",
|
||||
"@libraryFilterMetadataComplete": {
|
||||
"description": "Filter option - items with complete metadata"
|
||||
},
|
||||
"libraryFilterMetadataMissingAny": "Missing any metadata",
|
||||
"@libraryFilterMetadataMissingAny": {
|
||||
"description": "Filter option - items missing any tracked metadata field"
|
||||
},
|
||||
"libraryFilterMetadataMissingYear": "Missing year",
|
||||
"@libraryFilterMetadataMissingYear": {
|
||||
"description": "Filter option - items missing release year/date"
|
||||
},
|
||||
"libraryFilterMetadataMissingGenre": "Missing genre",
|
||||
"@libraryFilterMetadataMissingGenre": {
|
||||
"description": "Filter option - items missing genre"
|
||||
},
|
||||
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
|
||||
"@libraryFilterMetadataMissingAlbumArtist": {
|
||||
"description": "Filter option - items missing album artist"
|
||||
},
|
||||
"libraryFilterSort": "Sort",
|
||||
"@libraryFilterSort": {
|
||||
"description": "Filter section - sort order"
|
||||
@@ -2449,6 +2646,22 @@
|
||||
"@libraryFilterSortOldest": {
|
||||
"description": "Sort option - oldest first"
|
||||
},
|
||||
"libraryFilterSortAlbumAsc": "Album (A-Z)",
|
||||
"@libraryFilterSortAlbumAsc": {
|
||||
"description": "Sort option - album ascending"
|
||||
},
|
||||
"libraryFilterSortAlbumDesc": "Album (Z-A)",
|
||||
"@libraryFilterSortAlbumDesc": {
|
||||
"description": "Sort option - album descending"
|
||||
},
|
||||
"libraryFilterSortGenreAsc": "Genre (A-Z)",
|
||||
"@libraryFilterSortGenreAsc": {
|
||||
"description": "Sort option - genre ascending"
|
||||
},
|
||||
"libraryFilterSortGenreDesc": "Genre (Z-A)",
|
||||
"@libraryFilterSortGenreDesc": {
|
||||
"description": "Sort option - genre descending"
|
||||
},
|
||||
"timeJustNow": "Just now",
|
||||
"@timeJustNow": {
|
||||
"description": "Relative time - less than a minute ago"
|
||||
@@ -2535,7 +2748,7 @@
|
||||
"@tutorialExtensionsDesc": {
|
||||
"description": "Tutorial extensions page description"
|
||||
},
|
||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
||||
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
|
||||
"@tutorialExtensionsTip1": {
|
||||
"description": "Tutorial extensions tip 1"
|
||||
},
|
||||
@@ -2801,6 +3014,38 @@
|
||||
"@trackReEnrichOnlineSubtitle": {
|
||||
"description": "Subtitle for re-enrich metadata action for local items"
|
||||
},
|
||||
"trackReEnrichFieldsTitle": "Fields to update",
|
||||
"@trackReEnrichFieldsTitle": {
|
||||
"description": "Section title for field selection in re-enrich dialog"
|
||||
},
|
||||
"trackReEnrichFieldCover": "Cover Art",
|
||||
"@trackReEnrichFieldCover": {
|
||||
"description": "Checkbox label for cover art field in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldLyrics": "Lyrics",
|
||||
"@trackReEnrichFieldLyrics": {
|
||||
"description": "Checkbox label for lyrics field in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldBasicTags": "Album, Album Artist",
|
||||
"@trackReEnrichFieldBasicTags": {
|
||||
"description": "Checkbox label for basic tags in re-enrich (title/artist are never overwritten)"
|
||||
},
|
||||
"trackReEnrichFieldTrackInfo": "Track & Disc Number",
|
||||
"@trackReEnrichFieldTrackInfo": {
|
||||
"description": "Checkbox label for track info in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldReleaseInfo": "Date & ISRC",
|
||||
"@trackReEnrichFieldReleaseInfo": {
|
||||
"description": "Checkbox label for release info in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldExtra": "Genre, Label, Copyright",
|
||||
"@trackReEnrichFieldExtra": {
|
||||
"description": "Checkbox label for extra metadata in re-enrich"
|
||||
},
|
||||
"trackReEnrichSelectAll": "Select All",
|
||||
"@trackReEnrichSelectAll": {
|
||||
"description": "Select all fields checkbox in re-enrich"
|
||||
},
|
||||
"trackEditMetadata": "Edit Metadata",
|
||||
"@trackEditMetadata": {
|
||||
"description": "Menu action - edit embedded metadata"
|
||||
@@ -3398,10 +3643,6 @@
|
||||
"@lyricsProvidersDiscardContent": {
|
||||
"description": "Body text of the discard-changes dialog on lyrics provider page"
|
||||
},
|
||||
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
|
||||
"@lyricsProviderSpotifyApiDesc": {
|
||||
"description": "Description for Spotify Lyrics API provider"
|
||||
},
|
||||
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
|
||||
"@lyricsProviderLrclibDesc": {
|
||||
"description": "Description for LRCLIB provider"
|
||||
@@ -3586,6 +3827,22 @@
|
||||
"@downloadArtistNameFilters": {
|
||||
"description": "Setting title for artist folder filter options"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolder": "Create playlist source folder",
|
||||
"@downloadCreatePlaylistSourceFolder": {
|
||||
"description": "Setting title for adding a playlist folder prefix before the normal organization structure"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderEnabled": "Playlist downloads use Playlist/ plus your normal folder structure.",
|
||||
"@downloadCreatePlaylistSourceFolderEnabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is enabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderDisabled": "Playlist downloads use the normal folder structure only.",
|
||||
"@downloadCreatePlaylistSourceFolderDisabled": {
|
||||
"description": "Subtitle when playlist source folder prefix is disabled"
|
||||
},
|
||||
"downloadCreatePlaylistSourceFolderRedundant": "By Playlist already places downloads inside a playlist folder.",
|
||||
"@downloadCreatePlaylistSourceFolderRedundant": {
|
||||
"description": "Subtitle when playlist folder prefix setting is redundant because folder organization is already by playlist"
|
||||
},
|
||||
"downloadSongLinkRegion": "SongLink Region",
|
||||
"@downloadSongLinkRegion": {
|
||||
"description": "Setting title for SongLink country region"
|
||||
@@ -3847,5 +4104,441 @@
|
||||
"editMetadataSelectEmpty": "Empty only",
|
||||
"@editMetadataSelectEmpty": {
|
||||
"description": "Button to select only fields that are currently empty"
|
||||
},
|
||||
|
||||
"queueDownloadingCount": "Downloading ({count})",
|
||||
"@queueDownloadingCount": {
|
||||
"description": "Header for active downloads section with count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueDownloadedHeader": "Downloaded",
|
||||
"@queueDownloadedHeader": {
|
||||
"description": "Header label for downloaded items section in library"
|
||||
},
|
||||
"queueFilteringIndicator": "Filtering...",
|
||||
"@queueFilteringIndicator": {
|
||||
"description": "Shown while filter results are being computed"
|
||||
},
|
||||
"queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@queueTrackCount": {
|
||||
"description": "Track count label with plural support",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}",
|
||||
"@queueAlbumCount": {
|
||||
"description": "Album count label with plural support",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueEmptyAlbums": "No album downloads",
|
||||
"@queueEmptyAlbums": {
|
||||
"description": "Empty state title when no album downloads exist"
|
||||
},
|
||||
"queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here",
|
||||
"@queueEmptyAlbumsSubtitle": {
|
||||
"description": "Empty state subtitle for album downloads"
|
||||
},
|
||||
"queueEmptySingles": "No single downloads",
|
||||
"@queueEmptySingles": {
|
||||
"description": "Empty state title when no single track downloads exist"
|
||||
},
|
||||
"queueEmptySinglesSubtitle": "Single track downloads will appear here",
|
||||
"@queueEmptySinglesSubtitle": {
|
||||
"description": "Empty state subtitle for single track downloads"
|
||||
},
|
||||
"queueEmptyHistory": "No download history",
|
||||
"@queueEmptyHistory": {
|
||||
"description": "Empty state title when download history is empty"
|
||||
},
|
||||
"queueEmptyHistorySubtitle": "Downloaded tracks will appear here",
|
||||
"@queueEmptyHistorySubtitle": {
|
||||
"description": "Empty state subtitle for download history"
|
||||
},
|
||||
"selectionAllPlaylistsSelected": "All playlists selected",
|
||||
"@selectionAllPlaylistsSelected": {
|
||||
"description": "Shown when all playlists are selected in selection mode"
|
||||
},
|
||||
"selectionTapPlaylistsToSelect": "Tap playlists to select",
|
||||
"@selectionTapPlaylistsToSelect": {
|
||||
"description": "Hint shown in playlist selection mode"
|
||||
},
|
||||
"selectionSelectPlaylistsToDelete": "Select playlists to delete",
|
||||
"@selectionSelectPlaylistsToDelete": {
|
||||
"description": "Hint shown when no playlists are selected for deletion"
|
||||
},
|
||||
"audioAnalysisTitle": "Audio Quality Analysis",
|
||||
"@audioAnalysisTitle": {
|
||||
"description": "Title for audio analysis section"
|
||||
},
|
||||
"audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
|
||||
"@audioAnalysisDescription": {
|
||||
"description": "Description for audio analysis tap-to-analyze prompt"
|
||||
},
|
||||
"audioAnalysisAnalyzing": "Analyzing audio...",
|
||||
"@audioAnalysisAnalyzing": {
|
||||
"description": "Loading text while analyzing audio"
|
||||
},
|
||||
"audioAnalysisSampleRate": "Sample Rate",
|
||||
"@audioAnalysisSampleRate": {
|
||||
"description": "Sample rate metric label"
|
||||
},
|
||||
"audioAnalysisBitDepth": "Bit Depth",
|
||||
"@audioAnalysisBitDepth": {
|
||||
"description": "Bit depth metric label"
|
||||
},
|
||||
"audioAnalysisChannels": "Channels",
|
||||
"@audioAnalysisChannels": {
|
||||
"description": "Channels metric label"
|
||||
},
|
||||
"audioAnalysisDuration": "Duration",
|
||||
"@audioAnalysisDuration": {
|
||||
"description": "Duration metric label"
|
||||
},
|
||||
"audioAnalysisNyquist": "Nyquist",
|
||||
"@audioAnalysisNyquist": {
|
||||
"description": "Nyquist frequency metric label"
|
||||
},
|
||||
"audioAnalysisFileSize": "Size",
|
||||
"@audioAnalysisFileSize": {
|
||||
"description": "File size metric label"
|
||||
},
|
||||
"audioAnalysisDynamicRange": "Dynamic Range",
|
||||
"@audioAnalysisDynamicRange": {
|
||||
"description": "Dynamic range metric label"
|
||||
},
|
||||
"audioAnalysisPeak": "Peak",
|
||||
"@audioAnalysisPeak": {
|
||||
"description": "Peak amplitude metric label"
|
||||
},
|
||||
"audioAnalysisRms": "RMS",
|
||||
"@audioAnalysisRms": {
|
||||
"description": "RMS level metric label"
|
||||
},
|
||||
"audioAnalysisSamples": "Samples",
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsHomeFeedProvider": "Home Feed Provider",
|
||||
"@extensionsHomeFeedProvider": {
|
||||
"description": "Extensions page - label for home feed provider selector"
|
||||
},
|
||||
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
|
||||
"@extensionsHomeFeedDescription": {
|
||||
"description": "Extensions page - description for home feed provider picker"
|
||||
},
|
||||
"extensionsHomeFeedAuto": "Auto",
|
||||
"@extensionsHomeFeedAuto": {
|
||||
"description": "Extensions page - home feed provider option: auto"
|
||||
},
|
||||
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
|
||||
"@extensionsHomeFeedAutoSubtitle": {
|
||||
"description": "Extensions page - subtitle for auto home feed option"
|
||||
},
|
||||
"extensionsHomeFeedUse": "Use {extensionName} home feed",
|
||||
"@extensionsHomeFeedUse": {
|
||||
"description": "Extensions page - subtitle for a specific extension home feed option",
|
||||
"placeholders": {
|
||||
"extensionName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
|
||||
"@extensionsNoHomeFeedExtensions": {
|
||||
"description": "Extensions page - shown when no installed extension has home feed"
|
||||
},
|
||||
|
||||
"sortAlphaAsc": "A-Z",
|
||||
"@sortAlphaAsc": {
|
||||
"description": "Sort option - alphabetical ascending"
|
||||
},
|
||||
"sortAlphaDesc": "Z-A",
|
||||
"@sortAlphaDesc": {
|
||||
"description": "Sort option - alphabetical descending"
|
||||
},
|
||||
"cancelDownloadTitle": "Cancel download?",
|
||||
"@cancelDownloadTitle": {
|
||||
"description": "Dialog title when confirming cancellation of an active download"
|
||||
},
|
||||
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
|
||||
"@cancelDownloadContent": {
|
||||
"description": "Dialog body when confirming cancellation of an active download",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cancelDownloadKeep": "Keep",
|
||||
"@cancelDownloadKeep": {
|
||||
"description": "Dialog button - keep the active download (do not cancel)"
|
||||
},
|
||||
|
||||
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
|
||||
"@metadataSaveFailedFfmpeg": {
|
||||
"description": "Snackbar error when FFmpeg fails to write metadata"
|
||||
},
|
||||
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
|
||||
"@metadataSaveFailedStorage": {
|
||||
"description": "Snackbar error when writing metadata file back to storage fails"
|
||||
},
|
||||
|
||||
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
|
||||
"@snackbarFolderPickerFailed": {
|
||||
"description": "Snackbar shown when folder picker fails to open",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"errorLoadAlbum": "Failed to load album",
|
||||
"@errorLoadAlbum": {
|
||||
"description": "Error state shown when album fails to load"
|
||||
},
|
||||
"errorLoadPlaylist": "Failed to load playlist",
|
||||
"@errorLoadPlaylist": {
|
||||
"description": "Error state shown when playlist fails to load"
|
||||
},
|
||||
"errorLoadArtist": "Failed to load artist",
|
||||
"@errorLoadArtist": {
|
||||
"description": "Error state shown when artist fails to load"
|
||||
},
|
||||
|
||||
"notifChannelDownloadName": "Download Progress",
|
||||
"@notifChannelDownloadName": {
|
||||
"description": "Android notification channel name for download progress"
|
||||
},
|
||||
"notifChannelDownloadDesc": "Shows download progress for tracks",
|
||||
"@notifChannelDownloadDesc": {
|
||||
"description": "Android notification channel description for download progress"
|
||||
},
|
||||
"notifChannelLibraryScanName": "Library Scan",
|
||||
"@notifChannelLibraryScanName": {
|
||||
"description": "Android notification channel name for library scan"
|
||||
},
|
||||
"notifChannelLibraryScanDesc": "Shows local library scan progress",
|
||||
"@notifChannelLibraryScanDesc": {
|
||||
"description": "Android notification channel description for library scan"
|
||||
},
|
||||
"notifDownloadingTrack": "Downloading {trackName}",
|
||||
"@notifDownloadingTrack": {
|
||||
"description": "Notification title while downloading a track",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifFinalizingTrack": "Finalizing {trackName}",
|
||||
"@notifFinalizingTrack": {
|
||||
"description": "Notification title while finalizing (embedding metadata) a track",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifEmbeddingMetadata": "Embedding metadata...",
|
||||
"@notifEmbeddingMetadata": {
|
||||
"description": "Notification body while embedding metadata into a downloaded track"
|
||||
},
|
||||
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
|
||||
"@notifAlreadyInLibraryCount": {
|
||||
"description": "Notification title when track is already in library, with count",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifAlreadyInLibrary": "Already in Library",
|
||||
"@notifAlreadyInLibrary": {
|
||||
"description": "Notification title when track is already in library"
|
||||
},
|
||||
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
|
||||
"@notifDownloadCompleteCount": {
|
||||
"description": "Notification title when download is complete, with count",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifDownloadComplete": "Download Complete",
|
||||
"@notifDownloadComplete": {
|
||||
"description": "Notification title when a single download is complete"
|
||||
},
|
||||
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
|
||||
"@notifDownloadsFinished": {
|
||||
"description": "Notification title when queue finishes with some failures",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"failed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifAllDownloadsComplete": "All Downloads Complete",
|
||||
"@notifAllDownloadsComplete": {
|
||||
"description": "Notification title when all downloads finish successfully"
|
||||
},
|
||||
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
|
||||
"@notifTracksDownloadedSuccess": {
|
||||
"description": "Notification body for queue complete - how many tracks were downloaded",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifScanningLibrary": "Scanning local library",
|
||||
"@notifScanningLibrary": {
|
||||
"description": "Notification title while scanning local library"
|
||||
},
|
||||
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
|
||||
"@notifLibraryScanProgressWithTotal": {
|
||||
"description": "Notification body for library scan progress when total is known",
|
||||
"placeholders": {
|
||||
"scanned": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"percentage": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
|
||||
"@notifLibraryScanProgressNoTotal": {
|
||||
"description": "Notification body for library scan progress when total is unknown",
|
||||
"placeholders": {
|
||||
"scanned": {
|
||||
"type": "int"
|
||||
},
|
||||
"percentage": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanComplete": "Library scan complete",
|
||||
"@notifLibraryScanComplete": {
|
||||
"description": "Notification title when library scan finishes"
|
||||
},
|
||||
"notifLibraryScanCompleteBody": "{count} tracks indexed",
|
||||
"@notifLibraryScanCompleteBody": {
|
||||
"description": "Notification body for library scan complete - number of indexed tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanExcluded": "{count} excluded",
|
||||
"@notifLibraryScanExcluded": {
|
||||
"description": "Library scan complete suffix - excluded track count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanErrors": "{count} errors",
|
||||
"@notifLibraryScanErrors": {
|
||||
"description": "Library scan complete suffix - error count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanFailed": "Library scan failed",
|
||||
"@notifLibraryScanFailed": {
|
||||
"description": "Notification title when library scan fails"
|
||||
},
|
||||
"notifLibraryScanCancelled": "Library scan cancelled",
|
||||
"@notifLibraryScanCancelled": {
|
||||
"description": "Notification title when library scan is cancelled by the user"
|
||||
},
|
||||
"notifLibraryScanStopped": "Scan stopped before completion.",
|
||||
"@notifLibraryScanStopped": {
|
||||
"description": "Notification body when library scan is cancelled"
|
||||
},
|
||||
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
|
||||
"@notifDownloadingUpdate": {
|
||||
"description": "Notification title while downloading an app update",
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
|
||||
"@notifUpdateProgress": {
|
||||
"description": "Notification body showing update download progress",
|
||||
"placeholders": {
|
||||
"received": {
|
||||
"type": "String"
|
||||
},
|
||||
"total": {
|
||||
"type": "String"
|
||||
},
|
||||
"percentage": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifUpdateReady": "Update Ready",
|
||||
"@notifUpdateReady": {
|
||||
"description": "Notification title when app update download is complete"
|
||||
},
|
||||
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
|
||||
"@notifUpdateReadyBody": {
|
||||
"description": "Notification body when app update is ready to install",
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifUpdateFailed": "Update Failed",
|
||||
"@notifUpdateFailed": {
|
||||
"description": "Notification title when app update download fails"
|
||||
},
|
||||
"notifUpdateFailedBody": "Could not download update. Try again later.",
|
||||
"@notifUpdateFailedBody": {
|
||||
"description": "Notification body when app update download fails"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+409
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "No se puede cargar {item}: falta una fuente de extensión",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,10 +1003,26 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
"folderOrganizationNone": "Ninguna organización",
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Por Artista",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1749,6 +1777,14 @@
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Preguntar antes de descargar",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -2198,6 +2234,15 @@
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||
"@libraryTracksUnit": {
|
||||
"description": "Unit label for tracks count (without the number itself)",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2358,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2783,6 +2828,367 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
},
|
||||
"collectionFoldersTitle": "My folders",
|
||||
"@collectionFoldersTitle": {
|
||||
"description": "Library section title for custom folders"
|
||||
},
|
||||
"collectionWishlist": "Wishlist",
|
||||
"@collectionWishlist": {
|
||||
"description": "Custom folder for saved tracks to download later"
|
||||
},
|
||||
"collectionLoved": "Loved",
|
||||
"@collectionLoved": {
|
||||
"description": "Custom folder for favorite tracks"
|
||||
},
|
||||
"collectionPlaylists": "Playlists",
|
||||
"@collectionPlaylists": {
|
||||
"description": "Custom user playlists folder"
|
||||
},
|
||||
"collectionPlaylist": "Playlist",
|
||||
"@collectionPlaylist": {
|
||||
"description": "Single playlist label"
|
||||
},
|
||||
"collectionAddToPlaylist": "Add to playlist",
|
||||
"@collectionAddToPlaylist": {
|
||||
"description": "Action to add a track to user playlist"
|
||||
},
|
||||
"collectionCreatePlaylist": "Create playlist",
|
||||
"@collectionCreatePlaylist": {
|
||||
"description": "Action to create a new playlist"
|
||||
},
|
||||
"collectionNoPlaylistsYet": "No playlists yet",
|
||||
"@collectionNoPlaylistsYet": {
|
||||
"description": "Empty state title when user has no playlists"
|
||||
},
|
||||
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||
"@collectionNoPlaylistsSubtitle": {
|
||||
"description": "Empty state subtitle when user has no playlists"
|
||||
},
|
||||
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@collectionPlaylistTracks": {
|
||||
"description": "Track count label for custom playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||
"@collectionAddedToPlaylist": {
|
||||
"description": "Snackbar after adding track to playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||
"@collectionAlreadyInPlaylist": {
|
||||
"description": "Snackbar when track already exists in playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistCreated": "Playlist created",
|
||||
"@collectionPlaylistCreated": {
|
||||
"description": "Snackbar after creating playlist"
|
||||
},
|
||||
"collectionPlaylistNameHint": "Playlist name",
|
||||
"@collectionPlaylistNameHint": {
|
||||
"description": "Hint text for playlist name input"
|
||||
},
|
||||
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||
"@collectionPlaylistNameRequired": {
|
||||
"description": "Validation error for empty playlist name"
|
||||
},
|
||||
"collectionRenamePlaylist": "Rename playlist",
|
||||
"@collectionRenamePlaylist": {
|
||||
"description": "Action to rename playlist"
|
||||
},
|
||||
"collectionDeletePlaylist": "Delete playlist",
|
||||
"@collectionDeletePlaylist": {
|
||||
"description": "Action to delete playlist"
|
||||
},
|
||||
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||
"@collectionDeletePlaylistMessage": {
|
||||
"description": "Confirmation message for deleting playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistDeleted": "Playlist deleted",
|
||||
"@collectionPlaylistDeleted": {
|
||||
"description": "Snackbar after deleting playlist"
|
||||
},
|
||||
"collectionPlaylistRenamed": "Playlist renamed",
|
||||
"@collectionPlaylistRenamed": {
|
||||
"description": "Snackbar after renaming playlist"
|
||||
},
|
||||
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||
"@collectionWishlistEmptyTitle": {
|
||||
"description": "Wishlist empty state title"
|
||||
},
|
||||
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||
"@collectionWishlistEmptySubtitle": {
|
||||
"description": "Wishlist empty state subtitle"
|
||||
},
|
||||
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||
"@collectionLovedEmptyTitle": {
|
||||
"description": "Loved empty state title"
|
||||
},
|
||||
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||
"@collectionLovedEmptySubtitle": {
|
||||
"description": "Loved empty state subtitle"
|
||||
},
|
||||
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||
"@collectionPlaylistEmptyTitle": {
|
||||
"description": "Playlist empty state title"
|
||||
},
|
||||
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||
"@collectionPlaylistEmptySubtitle": {
|
||||
"description": "Playlist empty state subtitle"
|
||||
},
|
||||
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||
"@collectionRemoveFromPlaylist": {
|
||||
"description": "Tooltip for removing track from playlist"
|
||||
},
|
||||
"collectionRemoveFromFolder": "Remove from folder",
|
||||
"@collectionRemoveFromFolder": {
|
||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||
},
|
||||
"collectionRemoved": "\"{trackName}\" removed",
|
||||
"@collectionRemoved": {
|
||||
"description": "Snackbar after removing a track from a collection",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||
"@collectionAddedToLoved": {
|
||||
"description": "Snackbar after adding track to loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||
"@collectionRemovedFromLoved": {
|
||||
"description": "Snackbar after removing track from loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||
"@collectionAddedToWishlist": {
|
||||
"description": "Snackbar after adding track to wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||
"@collectionRemovedFromWishlist": {
|
||||
"description": "Snackbar after removing track from wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackOptionAddToLoved": "Add to Loved",
|
||||
"@trackOptionAddToLoved": {
|
||||
"description": "Bottom sheet action label - add track to loved folder"
|
||||
},
|
||||
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||
"@trackOptionRemoveFromLoved": {
|
||||
"description": "Bottom sheet action label - remove track from loved folder"
|
||||
},
|
||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||
"@trackOptionAddToWishlist": {
|
||||
"description": "Bottom sheet action label - add track to wishlist"
|
||||
},
|
||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||
"@trackOptionRemoveFromWishlist": {
|
||||
"description": "Bottom sheet action label - remove track from wishlist"
|
||||
},
|
||||
"collectionPlaylistChangeCover": "Change cover image",
|
||||
"@collectionPlaylistChangeCover": {
|
||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||
},
|
||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||
"@collectionPlaylistRemoveCover": {
|
||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||
},
|
||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "No shareable files found",
|
||||
"@selectionShareNoFiles": {
|
||||
"description": "Snackbar when no selected files exist on disk"
|
||||
},
|
||||
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionConvertCount": {
|
||||
"description": "Convert button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||
"@selectionConvertNoConvertible": {
|
||||
"description": "Snackbar when no selected tracks support conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||
"@selectionBatchConvertConfirmTitle": {
|
||||
"description": "Confirmation dialog title for batch conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
},
|
||||
"bitrate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||
"@selectionBatchConvertSuccess": {
|
||||
"description": "Snackbar after batch conversion completes",
|
||||
"placeholders": {
|
||||
"success": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumDownloadedCount": "{count} descargado",
|
||||
"@downloadedAlbumDownloadedCount": {
|
||||
"description": "Downloaded tracks count badge",
|
||||
@@ -2800,4 +3206,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1456
-48
File diff suppressed because it is too large
Load Diff
+1432
-24
File diff suppressed because it is too large
Load Diff
+1421
-82
File diff suppressed because it is too large
Load Diff
+1441
-33
File diff suppressed because it is too large
Load Diff
+1432
-24
File diff suppressed because it is too large
Load Diff
+1436
-28
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+409
-3
@@ -450,7 +450,7 @@
|
||||
"@aboutSpotiSaverDesc": {
|
||||
"description": "Credit for SpotiSaver API"
|
||||
},
|
||||
"aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.",
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -897,6 +897,18 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
},
|
||||
"errorUrlNotRecognizedMessage": "This link is not supported. Make sure the URL is correct and a compatible extension is installed.",
|
||||
"@errorUrlNotRecognizedMessage": {
|
||||
"description": "Error message - URL not recognized explanation"
|
||||
},
|
||||
"errorUrlFetchFailed": "Failed to load content from this link. Please try again.",
|
||||
"@errorUrlFetchFailed": {
|
||||
"description": "Error message - generic URL fetch failure"
|
||||
},
|
||||
"errorMissingExtensionSource": "Não é possível carregar {item}: faltando a fonte da extensão",
|
||||
"@errorMissingExtensionSource": {
|
||||
"description": "Error - extension source not available",
|
||||
@@ -991,10 +1003,26 @@
|
||||
"@filenameFormat": {
|
||||
"description": "Setting title - filename pattern"
|
||||
},
|
||||
"filenameShowAdvancedTags": "Show advanced tags",
|
||||
"@filenameShowAdvancedTags": {
|
||||
"description": "Toggle label for showing advanced filename tags"
|
||||
},
|
||||
"filenameShowAdvancedTagsDescription": "Enable formatted tags for track padding and date patterns",
|
||||
"@filenameShowAdvancedTagsDescription": {
|
||||
"description": "Description for advanced filename tag toggle"
|
||||
},
|
||||
"folderOrganizationNone": "Nenhuma organização",
|
||||
"@folderOrganizationNone": {
|
||||
"description": "Folder option - flat structure"
|
||||
},
|
||||
"folderOrganizationByPlaylist": "By Playlist",
|
||||
"@folderOrganizationByPlaylist": {
|
||||
"description": "Folder option - playlist folders"
|
||||
},
|
||||
"folderOrganizationByPlaylistSubtitle": "Separate folder for each playlist",
|
||||
"@folderOrganizationByPlaylistSubtitle": {
|
||||
"description": "Subtitle for playlist folder option"
|
||||
},
|
||||
"folderOrganizationByArtist": "Por Artista",
|
||||
"@folderOrganizationByArtist": {
|
||||
"description": "Folder option - artist folders"
|
||||
@@ -1749,6 +1777,14 @@
|
||||
"@youtubeQualityNote": {
|
||||
"description": "Note for YouTube service explaining lossy-only quality"
|
||||
},
|
||||
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
|
||||
"@youtubeOpusBitrateTitle": {
|
||||
"description": "Title for YouTube Opus bitrate setting"
|
||||
},
|
||||
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
|
||||
"@youtubeMp3BitrateTitle": {
|
||||
"description": "Title for YouTube MP3 bitrate setting"
|
||||
},
|
||||
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
|
||||
"@downloadAskBeforeDownload": {
|
||||
"description": "Setting - show quality picker"
|
||||
@@ -2198,6 +2234,15 @@
|
||||
"@libraryAboutDescription": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
"libraryTracksUnit": "{count, plural, =1{track} other{tracks}}",
|
||||
"@libraryTracksUnit": {
|
||||
"description": "Unit label for tracks count (without the number itself)",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2358,7 +2403,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"description": "Tutorial welcome tip 1"
|
||||
},
|
||||
"tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer",
|
||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
||||
"@tutorialWelcomeTip2": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -2783,6 +2828,367 @@
|
||||
"@trackConvertFailed": {
|
||||
"description": "Snackbar when conversion fails"
|
||||
},
|
||||
"cueSplitTitle": "Split CUE Sheet",
|
||||
"@cueSplitTitle": {
|
||||
"description": "Title for CUE split bottom sheet"
|
||||
},
|
||||
"cueSplitSubtitle": "Split CUE+FLAC into individual tracks",
|
||||
"@cueSplitSubtitle": {
|
||||
"description": "Subtitle for CUE split menu item"
|
||||
},
|
||||
"cueSplitAlbum": "Album: {album}",
|
||||
"@cueSplitAlbum": {
|
||||
"description": "Album name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitArtist": "Artist: {artist}",
|
||||
"@cueSplitArtist": {
|
||||
"description": "Artist name in CUE split sheet",
|
||||
"placeholders": {
|
||||
"artist": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitTrackCount": "{count} tracks",
|
||||
"@cueSplitTrackCount": {
|
||||
"description": "Number of tracks in CUE sheet",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitConfirmTitle": "Split CUE Album",
|
||||
"@cueSplitConfirmTitle": {
|
||||
"description": "CUE split confirmation dialog title"
|
||||
},
|
||||
"cueSplitConfirmMessage": "Split \"{album}\" into {count} individual FLAC files?\n\nFiles will be saved to the same directory.",
|
||||
"@cueSplitConfirmMessage": {
|
||||
"description": "CUE split confirmation dialog message",
|
||||
"placeholders": {
|
||||
"album": {
|
||||
"type": "String"
|
||||
},
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSplitting": "Splitting CUE sheet... ({current}/{total})",
|
||||
"@cueSplitSplitting": {
|
||||
"description": "Snackbar while splitting CUE",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitSuccess": "Split into {count} tracks successfully",
|
||||
"@cueSplitSuccess": {
|
||||
"description": "Snackbar after successful CUE split",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cueSplitFailed": "CUE split failed",
|
||||
"@cueSplitFailed": {
|
||||
"description": "Snackbar when CUE split fails"
|
||||
},
|
||||
"cueSplitNoAudioFile": "Audio file not found for this CUE sheet",
|
||||
"@cueSplitNoAudioFile": {
|
||||
"description": "Error when CUE audio file is missing"
|
||||
},
|
||||
"cueSplitButton": "Split into Tracks",
|
||||
"@cueSplitButton": {
|
||||
"description": "Button text to start CUE splitting"
|
||||
},
|
||||
"actionCreate": "Create",
|
||||
"@actionCreate": {
|
||||
"description": "Generic action button - create"
|
||||
},
|
||||
"collectionFoldersTitle": "My folders",
|
||||
"@collectionFoldersTitle": {
|
||||
"description": "Library section title for custom folders"
|
||||
},
|
||||
"collectionWishlist": "Wishlist",
|
||||
"@collectionWishlist": {
|
||||
"description": "Custom folder for saved tracks to download later"
|
||||
},
|
||||
"collectionLoved": "Loved",
|
||||
"@collectionLoved": {
|
||||
"description": "Custom folder for favorite tracks"
|
||||
},
|
||||
"collectionPlaylists": "Playlists",
|
||||
"@collectionPlaylists": {
|
||||
"description": "Custom user playlists folder"
|
||||
},
|
||||
"collectionPlaylist": "Playlist",
|
||||
"@collectionPlaylist": {
|
||||
"description": "Single playlist label"
|
||||
},
|
||||
"collectionAddToPlaylist": "Add to playlist",
|
||||
"@collectionAddToPlaylist": {
|
||||
"description": "Action to add a track to user playlist"
|
||||
},
|
||||
"collectionCreatePlaylist": "Create playlist",
|
||||
"@collectionCreatePlaylist": {
|
||||
"description": "Action to create a new playlist"
|
||||
},
|
||||
"collectionNoPlaylistsYet": "No playlists yet",
|
||||
"@collectionNoPlaylistsYet": {
|
||||
"description": "Empty state title when user has no playlists"
|
||||
},
|
||||
"collectionNoPlaylistsSubtitle": "Create a playlist to start categorizing tracks",
|
||||
"@collectionNoPlaylistsSubtitle": {
|
||||
"description": "Empty state subtitle when user has no playlists"
|
||||
},
|
||||
"collectionPlaylistTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||
"@collectionPlaylistTracks": {
|
||||
"description": "Track count label for custom playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToPlaylist": "Added to \"{playlistName}\"",
|
||||
"@collectionAddedToPlaylist": {
|
||||
"description": "Snackbar after adding track to playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAlreadyInPlaylist": "Already in \"{playlistName}\"",
|
||||
"@collectionAlreadyInPlaylist": {
|
||||
"description": "Snackbar when track already exists in playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistCreated": "Playlist created",
|
||||
"@collectionPlaylistCreated": {
|
||||
"description": "Snackbar after creating playlist"
|
||||
},
|
||||
"collectionPlaylistNameHint": "Playlist name",
|
||||
"@collectionPlaylistNameHint": {
|
||||
"description": "Hint text for playlist name input"
|
||||
},
|
||||
"collectionPlaylistNameRequired": "Playlist name is required",
|
||||
"@collectionPlaylistNameRequired": {
|
||||
"description": "Validation error for empty playlist name"
|
||||
},
|
||||
"collectionRenamePlaylist": "Rename playlist",
|
||||
"@collectionRenamePlaylist": {
|
||||
"description": "Action to rename playlist"
|
||||
},
|
||||
"collectionDeletePlaylist": "Delete playlist",
|
||||
"@collectionDeletePlaylist": {
|
||||
"description": "Action to delete playlist"
|
||||
},
|
||||
"collectionDeletePlaylistMessage": "Delete \"{playlistName}\" and all tracks inside it?",
|
||||
"@collectionDeletePlaylistMessage": {
|
||||
"description": "Confirmation message for deleting playlist",
|
||||
"placeholders": {
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistDeleted": "Playlist deleted",
|
||||
"@collectionPlaylistDeleted": {
|
||||
"description": "Snackbar after deleting playlist"
|
||||
},
|
||||
"collectionPlaylistRenamed": "Playlist renamed",
|
||||
"@collectionPlaylistRenamed": {
|
||||
"description": "Snackbar after renaming playlist"
|
||||
},
|
||||
"collectionWishlistEmptyTitle": "Wishlist is empty",
|
||||
"@collectionWishlistEmptyTitle": {
|
||||
"description": "Wishlist empty state title"
|
||||
},
|
||||
"collectionWishlistEmptySubtitle": "Tap + on tracks to save what you want to download later",
|
||||
"@collectionWishlistEmptySubtitle": {
|
||||
"description": "Wishlist empty state subtitle"
|
||||
},
|
||||
"collectionLovedEmptyTitle": "Loved folder is empty",
|
||||
"@collectionLovedEmptyTitle": {
|
||||
"description": "Loved empty state title"
|
||||
},
|
||||
"collectionLovedEmptySubtitle": "Tap love on tracks to keep your favorites",
|
||||
"@collectionLovedEmptySubtitle": {
|
||||
"description": "Loved empty state subtitle"
|
||||
},
|
||||
"collectionPlaylistEmptyTitle": "Playlist is empty",
|
||||
"@collectionPlaylistEmptyTitle": {
|
||||
"description": "Playlist empty state title"
|
||||
},
|
||||
"collectionPlaylistEmptySubtitle": "Long-press + on any track to add it here",
|
||||
"@collectionPlaylistEmptySubtitle": {
|
||||
"description": "Playlist empty state subtitle"
|
||||
},
|
||||
"collectionRemoveFromPlaylist": "Remove from playlist",
|
||||
"@collectionRemoveFromPlaylist": {
|
||||
"description": "Tooltip for removing track from playlist"
|
||||
},
|
||||
"collectionRemoveFromFolder": "Remove from folder",
|
||||
"@collectionRemoveFromFolder": {
|
||||
"description": "Tooltip for removing track from wishlist/loved folder"
|
||||
},
|
||||
"collectionRemoved": "\"{trackName}\" removed",
|
||||
"@collectionRemoved": {
|
||||
"description": "Snackbar after removing a track from a collection",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToLoved": "\"{trackName}\" added to Loved",
|
||||
"@collectionAddedToLoved": {
|
||||
"description": "Snackbar after adding track to loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromLoved": "\"{trackName}\" removed from Loved",
|
||||
"@collectionRemovedFromLoved": {
|
||||
"description": "Snackbar after removing track from loved folder",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedToWishlist": "\"{trackName}\" added to Wishlist",
|
||||
"@collectionAddedToWishlist": {
|
||||
"description": "Snackbar after adding track to wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionRemovedFromWishlist": "\"{trackName}\" removed from Wishlist",
|
||||
"@collectionRemovedFromWishlist": {
|
||||
"description": "Snackbar after removing track from wishlist",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackOptionAddToLoved": "Add to Loved",
|
||||
"@trackOptionAddToLoved": {
|
||||
"description": "Bottom sheet action label - add track to loved folder"
|
||||
},
|
||||
"trackOptionRemoveFromLoved": "Remove from Loved",
|
||||
"@trackOptionRemoveFromLoved": {
|
||||
"description": "Bottom sheet action label - remove track from loved folder"
|
||||
},
|
||||
"trackOptionAddToWishlist": "Add to Wishlist",
|
||||
"@trackOptionAddToWishlist": {
|
||||
"description": "Bottom sheet action label - add track to wishlist"
|
||||
},
|
||||
"trackOptionRemoveFromWishlist": "Remove from Wishlist",
|
||||
"@trackOptionRemoveFromWishlist": {
|
||||
"description": "Bottom sheet action label - remove track from wishlist"
|
||||
},
|
||||
"collectionPlaylistChangeCover": "Change cover image",
|
||||
"@collectionPlaylistChangeCover": {
|
||||
"description": "Bottom sheet action to pick a custom cover image for a playlist"
|
||||
},
|
||||
"collectionPlaylistRemoveCover": "Remove cover image",
|
||||
"@collectionPlaylistRemoveCover": {
|
||||
"description": "Bottom sheet action to remove custom cover image from a playlist"
|
||||
},
|
||||
"selectionShareCount": "Share {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionShareCount": {
|
||||
"description": "Share button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionShareNoFiles": "No shareable files found",
|
||||
"@selectionShareNoFiles": {
|
||||
"description": "Snackbar when no selected files exist on disk"
|
||||
},
|
||||
"selectionConvertCount": "Convert {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionConvertCount": {
|
||||
"description": "Convert button text with count in selection mode",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionConvertNoConvertible": "No convertible tracks selected",
|
||||
"@selectionConvertNoConvertible": {
|
||||
"description": "Snackbar when no selected tracks support conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmTitle": "Batch Convert",
|
||||
"@selectionBatchConvertConfirmTitle": {
|
||||
"description": "Confirmation dialog title for batch conversion"
|
||||
},
|
||||
"selectionBatchConvertConfirmMessage": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} at {bitrate}?\n\nOriginal files will be deleted after conversion.",
|
||||
"@selectionBatchConvertConfirmMessage": {
|
||||
"description": "Confirmation dialog message for batch conversion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
},
|
||||
"bitrate": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertProgress": "Converting {current} of {total}...",
|
||||
"@selectionBatchConvertProgress": {
|
||||
"description": "Snackbar during batch conversion progress",
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionBatchConvertSuccess": "Converted {success} of {total} tracks to {format}",
|
||||
"@selectionBatchConvertSuccess": {
|
||||
"description": "Snackbar after batch conversion completes",
|
||||
"placeholders": {
|
||||
"success": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"format": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadedAlbumDownloadedCount": "{count} baixado(s)",
|
||||
"@downloadedAlbumDownloadedCount": {
|
||||
"description": "Downloaded tracks count badge",
|
||||
@@ -2800,4 +3206,4 @@
|
||||
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
|
||||
"description": "Subtitle when Track Artist is used for folder naming"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1450
-42
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+544
-138
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1525
-117
File diff suppressed because it is too large
Load Diff
+1433
-25
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -222,10 +222,12 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
|
||||
// All checks passed -- start an incremental scan.
|
||||
final iosBookmark = settings.localLibraryBookmark;
|
||||
ref.read(localLibraryProvider.notifier).startScan(
|
||||
settings.localLibraryPath,
|
||||
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||
);
|
||||
ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.startScan(
|
||||
settings.localLibraryPath,
|
||||
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initializeAppServices() async {
|
||||
|
||||
@@ -20,6 +20,7 @@ class AppSettings {
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
final String folderOrganization;
|
||||
final bool createPlaylistFolder;
|
||||
final bool useAlbumArtistForFolders;
|
||||
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
|
||||
final bool filterContributingArtistsInAlbumArtist;
|
||||
@@ -33,11 +34,14 @@ class AppSettings {
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final String? searchProvider;
|
||||
final String? homeFeedProvider;
|
||||
final bool separateSingles;
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
|
||||
final int
|
||||
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
|
||||
final int
|
||||
@@ -96,6 +100,7 @@ class AppSettings {
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
this.folderOrganization = 'none',
|
||||
this.createPlaylistFolder = false,
|
||||
this.useAlbumArtistForFolders = true,
|
||||
this.usePrimaryArtistOnly = false,
|
||||
this.filterContributingArtistsInAlbumArtist = false,
|
||||
@@ -109,11 +114,13 @@ class AppSettings {
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.searchProvider,
|
||||
this.homeFeedProvider,
|
||||
this.separateSingles = false,
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
this.youtubeOpusBitrate = 256,
|
||||
this.youtubeMp3Bitrate = 320,
|
||||
this.useAllFilesAccess = false,
|
||||
@@ -159,6 +166,7 @@ class AppSettings {
|
||||
String? updateChannel,
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
bool? createPlaylistFolder,
|
||||
bool? useAlbumArtistForFolders,
|
||||
bool? usePrimaryArtistOnly,
|
||||
bool? filterContributingArtistsInAlbumArtist,
|
||||
@@ -173,11 +181,14 @@ class AppSettings {
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false,
|
||||
String? homeFeedProvider,
|
||||
bool clearHomeFeedProvider = false,
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
int? youtubeOpusBitrate,
|
||||
int? youtubeMp3Bitrate,
|
||||
bool? useAllFilesAccess,
|
||||
@@ -215,6 +226,7 @@ class AppSettings {
|
||||
updateChannel: updateChannel ?? this.updateChannel,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
createPlaylistFolder: createPlaylistFolder ?? this.createPlaylistFolder,
|
||||
useAlbumArtistForFolders:
|
||||
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
|
||||
@@ -236,11 +248,15 @@ class AppSettings {
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
homeFeedProvider: clearHomeFeedProvider
|
||||
? null
|
||||
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
|
||||
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
|
||||
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
|
||||
@@ -255,8 +271,7 @@ class AppSettings {
|
||||
localLibraryBookmark: localLibraryBookmark ?? this.localLibraryBookmark,
|
||||
localLibraryShowDuplicates:
|
||||
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
|
||||
localLibraryAutoScan:
|
||||
localLibraryAutoScan ?? this.localLibraryAutoScan,
|
||||
localLibraryAutoScan: localLibraryAutoScan ?? this.localLibraryAutoScan,
|
||||
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
|
||||
lyricsProviders: lyricsProviders ?? this.lyricsProviders,
|
||||
lyricsIncludeTranslationNetease:
|
||||
|
||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
createPlaylistFolder: json['createPlaylistFolder'] as bool? ?? false,
|
||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
@@ -38,12 +39,14 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure:
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
@@ -100,6 +103,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'createPlaylistFolder': instance.createPlaylistFolder,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
|
||||
'filterContributingArtistsInAlbumArtist':
|
||||
@@ -114,11 +118,13 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'homeFeedProvider': instance.homeFeedProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
|
||||
@@ -122,7 +122,7 @@ class DownloadHistoryItem {
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: normalizeOptionalString(json['albumArtist'] as String?),
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
coverUrl: normalizeCoverReference(json['coverUrl']?.toString()),
|
||||
filePath: json['filePath'] as String,
|
||||
storageMode: json['storageMode'] as String?,
|
||||
downloadTreeUri: json['downloadTreeUri'] as String?,
|
||||
@@ -176,7 +176,7 @@ class DownloadHistoryItem {
|
||||
artistName: artistName ?? this.artistName,
|
||||
albumName: albumName ?? this.albumName,
|
||||
albumArtist: albumArtist ?? this.albumArtist,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl),
|
||||
filePath: filePath ?? this.filePath,
|
||||
storageMode: storageMode ?? this.storageMode,
|
||||
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
|
||||
@@ -1651,12 +1651,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String folderOrganization, {
|
||||
bool separateSingles = false,
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool createPlaylistFolder = false,
|
||||
bool useAlbumArtistForFolders = true,
|
||||
bool usePrimaryArtistOnly = false,
|
||||
bool filterContributingArtistsInAlbumArtist = false,
|
||||
String? playlistName,
|
||||
}) async {
|
||||
String baseDir = state.outputDir;
|
||||
if (createPlaylistFolder &&
|
||||
folderOrganization != 'playlist' &&
|
||||
playlistName != null &&
|
||||
playlistName.isNotEmpty) {
|
||||
final playlistFolder = _sanitizeFolderName(playlistName);
|
||||
if (playlistFolder.isNotEmpty) {
|
||||
baseDir = '$baseDir${Platform.pathSeparator}$playlistFolder';
|
||||
await _ensureDirExists(baseDir, label: 'Playlist folder');
|
||||
}
|
||||
}
|
||||
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
|
||||
var folderArtist = useAlbumArtistForFolders
|
||||
? normalizedAlbumArtist ?? track.artistName
|
||||
@@ -1809,11 +1820,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String folderOrganization, {
|
||||
bool separateSingles = false,
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool createPlaylistFolder = false,
|
||||
bool useAlbumArtistForFolders = true,
|
||||
bool usePrimaryArtistOnly = false,
|
||||
bool filterContributingArtistsInAlbumArtist = false,
|
||||
String? playlistName,
|
||||
}) async {
|
||||
final playlistPrefix =
|
||||
createPlaylistFolder &&
|
||||
folderOrganization != 'playlist' &&
|
||||
playlistName != null &&
|
||||
playlistName.isNotEmpty
|
||||
? _sanitizeFolderName(playlistName)
|
||||
: '';
|
||||
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
|
||||
var folderArtist = useAlbumArtistForFolders
|
||||
? normalizedAlbumArtist ?? track.artistName
|
||||
@@ -1833,34 +1852,40 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (albumFolderStructure == 'artist_album_singles') {
|
||||
if (isSingle) {
|
||||
return '$artistName/Singles';
|
||||
return _joinRelativePath(playlistPrefix, '$artistName/Singles');
|
||||
}
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
return '$artistName/$albumName';
|
||||
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
|
||||
}
|
||||
|
||||
if (isSingle) {
|
||||
return 'Singles';
|
||||
return _joinRelativePath(playlistPrefix, 'Singles');
|
||||
}
|
||||
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final year = _extractYear(track.releaseDate);
|
||||
switch (albumFolderStructure) {
|
||||
case 'album_only':
|
||||
return 'Albums/$albumName';
|
||||
return _joinRelativePath(playlistPrefix, 'Albums/$albumName');
|
||||
case 'artist_year_album':
|
||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||
return 'Albums/$artistName/$yearAlbum';
|
||||
return _joinRelativePath(
|
||||
playlistPrefix,
|
||||
'Albums/$artistName/$yearAlbum',
|
||||
);
|
||||
case 'year_album':
|
||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||
return 'Albums/$yearAlbum';
|
||||
return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum');
|
||||
default:
|
||||
return 'Albums/$artistName/$albumName';
|
||||
return _joinRelativePath(
|
||||
playlistPrefix,
|
||||
'Albums/$artistName/$albumName',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderOrganization == 'none') {
|
||||
return '';
|
||||
return playlistPrefix;
|
||||
}
|
||||
|
||||
switch (folderOrganization) {
|
||||
@@ -1870,18 +1895,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
return '';
|
||||
case 'artist':
|
||||
return _sanitizeFolderName(folderArtist);
|
||||
return _joinRelativePath(
|
||||
playlistPrefix,
|
||||
_sanitizeFolderName(folderArtist),
|
||||
);
|
||||
case 'album':
|
||||
return _sanitizeFolderName(track.albumName);
|
||||
return _joinRelativePath(
|
||||
playlistPrefix,
|
||||
_sanitizeFolderName(track.albumName),
|
||||
);
|
||||
case 'artist_album':
|
||||
final artistName = _sanitizeFolderName(folderArtist);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
return '$artistName/$albumName';
|
||||
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
|
||||
default:
|
||||
return '';
|
||||
return playlistPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
String _joinRelativePath(String prefix, String suffix) {
|
||||
if (prefix.isEmpty) return suffix;
|
||||
if (suffix.isEmpty) return prefix;
|
||||
return '$prefix/$suffix';
|
||||
}
|
||||
|
||||
String _determineOutputExt(String quality, String service) {
|
||||
if (service.toLowerCase() == 'youtube') {
|
||||
if (quality.toLowerCase().contains('mp3')) {
|
||||
@@ -1890,7 +1927,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return '.opus';
|
||||
}
|
||||
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
|
||||
return '.flac'; // HIGH quality no longer available; fallback to FLAC
|
||||
return '.m4a';
|
||||
}
|
||||
return '.flac';
|
||||
}
|
||||
@@ -2497,8 +2534,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendIsrc = normalizeOptionalString(
|
||||
backendResult['isrc'] as String?,
|
||||
);
|
||||
final backendCoverUrl = normalizeOptionalString(
|
||||
backendResult['cover_url'] as String?,
|
||||
final backendCoverUrl = normalizeCoverReference(
|
||||
backendResult['cover_url']?.toString(),
|
||||
);
|
||||
final backendAlbumArtist = normalizeOptionalString(
|
||||
backendResult['album_artist'] as String?,
|
||||
@@ -2554,7 +2591,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
if (settings.maxQualityCover) {
|
||||
@@ -2740,7 +2777,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
if (settings.maxQualityCover) {
|
||||
@@ -2908,7 +2945,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
String? coverPath;
|
||||
var coverUrl = track.coverUrl;
|
||||
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
if (settings.maxQualityCover) {
|
||||
@@ -3248,6 +3285,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
|
||||
await _processQueueParallel();
|
||||
final stoppedWhilePaused = state.isPaused;
|
||||
|
||||
_stopProgressPolling();
|
||||
|
||||
@@ -3273,7 +3311,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.i(
|
||||
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||
);
|
||||
if (_totalQueuedAtStart > 0) {
|
||||
if (!stoppedWhilePaused && _totalQueuedAtStart > 0) {
|
||||
await _notificationService.showQueueComplete(
|
||||
completedCount: _completedInSession,
|
||||
failedCount: _failedInSession,
|
||||
@@ -3288,13 +3326,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
_log.i('Queue processing finished');
|
||||
if (stoppedWhilePaused) {
|
||||
_log.i('Queue processing paused');
|
||||
} else {
|
||||
_log.i('Queue processing finished');
|
||||
}
|
||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||
|
||||
final hasQueuedItems = state.items.any(
|
||||
(item) => item.status == DownloadStatus.queued,
|
||||
);
|
||||
if (hasQueuedItems) {
|
||||
if (hasQueuedItems && !state.isPaused) {
|
||||
_log.i(
|
||||
'Found queued items after processing finished, restarting queue...',
|
||||
);
|
||||
@@ -3310,8 +3352,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
while (true) {
|
||||
if (state.isPaused) {
|
||||
if (activeDownloads.isEmpty) {
|
||||
_log.d('Queue is paused and no active downloads remain');
|
||||
break;
|
||||
}
|
||||
_log.d('Queue is paused, waiting for active downloads...');
|
||||
await Future.delayed(_queueSchedulingInterval);
|
||||
await Future.any([
|
||||
Future.wait(activeDownloads.values),
|
||||
Future.delayed(_queueSchedulingInterval),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3535,6 +3584,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
createPlaylistFolder: settings.createPlaylistFolder,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
@@ -3550,6 +3600,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
createPlaylistFolder: settings.createPlaylistFolder,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
@@ -3582,6 +3633,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String? genre;
|
||||
String? label;
|
||||
String? copyright;
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final selectedExtensionDownloadProvider =
|
||||
settings.useExtensionProviders &&
|
||||
extensionState.extensions.any(
|
||||
(e) =>
|
||||
e.enabled &&
|
||||
e.hasDownloadProvider &&
|
||||
e.id.toLowerCase() == item.service.toLowerCase(),
|
||||
);
|
||||
final trackSource = (trackToDownload.source ?? '').trim().toLowerCase();
|
||||
final shouldSkipExtensionSongLinkPrelookup =
|
||||
trackSource.isNotEmpty &&
|
||||
extensionState.extensions.any(
|
||||
(e) =>
|
||||
e.enabled &&
|
||||
e.hasMetadataProvider &&
|
||||
e.id.toLowerCase() == trackSource,
|
||||
);
|
||||
|
||||
String? deezerTrackId = trackToDownload.deezerId;
|
||||
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
|
||||
@@ -3616,7 +3685,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
||||
if (deezerTrackId == null &&
|
||||
if (!selectedExtensionDownloadProvider &&
|
||||
deezerTrackId == null &&
|
||||
!shouldSkipExtensionSongLinkPrelookup &&
|
||||
trackToDownload.id.isNotEmpty &&
|
||||
!trackToDownload.id.startsWith('deezer:') &&
|
||||
!trackToDownload.id.startsWith('extension:')) {
|
||||
@@ -3680,7 +3751,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
artistId: trackToDownload.artistId,
|
||||
albumId: trackToDownload.albumId,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
|
||||
duration: trackToDownload.duration,
|
||||
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
|
||||
? deezerIsrc
|
||||
@@ -3717,6 +3788,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (shouldAbortWork('during SongLink availability lookup')) {
|
||||
return;
|
||||
}
|
||||
} else if (selectedExtensionDownloadProvider && deezerTrackId == null) {
|
||||
_log.d(
|
||||
'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}',
|
||||
);
|
||||
} else if (shouldSkipExtensionSongLinkPrelookup &&
|
||||
deezerTrackId == null) {
|
||||
_log.d(
|
||||
'Skipping Flutter SongLink Deezer prelookup for extension-sourced track; backend metadata enrichment will resolve identifiers first',
|
||||
);
|
||||
}
|
||||
|
||||
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
|
||||
@@ -3744,7 +3824,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
Map<String, dynamic> result;
|
||||
|
||||
final extensionState = ref.read(extensionProvider);
|
||||
final hasActiveExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled,
|
||||
);
|
||||
@@ -3880,10 +3959,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
createPlaylistFolder: settings.createPlaylistFolder,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
||||
filterContributingArtistsInAlbumArtist:
|
||||
settings.filterContributingArtistsInAlbumArtist,
|
||||
playlistName: item.playlistName,
|
||||
);
|
||||
final fallbackResult = await runDownload(
|
||||
useSaf: false,
|
||||
@@ -3960,6 +4041,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
item.service.toLowerCase();
|
||||
final decryptionKey =
|
||||
(result['decryption_key'] as String?)?.trim() ?? '';
|
||||
trackToDownload = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
_log.d(
|
||||
'Track coverUrl after download result: ${trackToDownload.coverUrl}',
|
||||
);
|
||||
|
||||
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
|
||||
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
|
||||
@@ -4097,50 +4186,73 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final currentFilePath = filePath;
|
||||
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
_log.d('M4A file detected (SAF), converting to FLAC...');
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
if (tempPath != null) {
|
||||
String? flacPath;
|
||||
try {
|
||||
final length = await File(tempPath).length();
|
||||
if (length < 1024) {
|
||||
_log.w('Temp M4A is too small (<1KB), skipping conversion');
|
||||
} else {
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
if (tempPath != null) {
|
||||
String? convertedPath;
|
||||
try {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
||||
if (flacPath != null) {
|
||||
_log.d('Converted to FLAC (temp): $flacPath');
|
||||
_log.d('Embedding metadata and cover to converted FLAC...');
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
|
||||
final format = tidalHighFormat.startsWith('opus')
|
||||
? 'opus'
|
||||
: 'mp3';
|
||||
convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
tempPath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: false,
|
||||
);
|
||||
|
||||
if (convertedPath != null) {
|
||||
_log.i(
|
||||
'Successfully converted M4A to $format (temp): $convertedPath',
|
||||
);
|
||||
_log.i('Embedding metadata to $format...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.99,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
await _embedMetadataAndCover(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
if (format == 'mp3') {
|
||||
await _embedMetadataToMp3(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
|
||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||
final newExt = format == 'opus' ? '.opus' : '.mp3';
|
||||
final newFileName = '${safBaseName ?? 'track'}$newExt';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: newFileName,
|
||||
mimeType: _mimeTypeForExt('.flac'),
|
||||
srcPath: flacPath,
|
||||
mimeType: _mimeTypeForExt(newExt),
|
||||
srcPath: convertedPath,
|
||||
);
|
||||
|
||||
if (newUri != null) {
|
||||
@@ -4149,60 +4261,57 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
||||
} else {
|
||||
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
||||
_log.w(
|
||||
'Failed to write converted $format to SAF, keeping M4A',
|
||||
);
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} else {
|
||||
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||
_log.w(
|
||||
'M4A to $format conversion failed, keeping M4A file',
|
||||
);
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A conversion failed: $e');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
} finally {
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
if (convertedPath != null) {
|
||||
try {
|
||||
await File(convertedPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A->FLAC conversion failed: $e');
|
||||
} finally {
|
||||
// Clean up temp files
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
if (flacPath != null) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_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 {
|
||||
} else {
|
||||
_log.d('M4A file detected (SAF), converting to FLAC...');
|
||||
final tempPath = await _copySafToTemp(currentFilePath);
|
||||
if (tempPath != null) {
|
||||
String? flacPath;
|
||||
try {
|
||||
final length = await File(tempPath).length();
|
||||
if (length < 1024) {
|
||||
_log.w('Temp M4A is too small (<1KB), skipping conversion');
|
||||
} else {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
|
||||
if (flacPath != null) {
|
||||
_log.d('Converted to FLAC (temp): $flacPath');
|
||||
_log.d(
|
||||
'Embedding metadata and cover to converted FLAC...',
|
||||
);
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
@@ -4213,32 +4322,200 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (backendGenre != null ||
|
||||
backendLabel != null ||
|
||||
backendCopyright != null) {
|
||||
_log.d(
|
||||
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
||||
);
|
||||
}
|
||||
|
||||
await _embedMetadataAndCover(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
writeExternalLrc: false,
|
||||
);
|
||||
|
||||
final newFileName = '${safBaseName ?? 'track'}.flac';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: newFileName,
|
||||
mimeType: _mimeTypeForExt('.flac'),
|
||||
srcPath: flacPath,
|
||||
);
|
||||
|
||||
if (newUri != null) {
|
||||
if (newUri != currentFilePath) {
|
||||
await _deleteSafFile(currentFilePath);
|
||||
}
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
} else {
|
||||
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'FFmpeg conversion returned null, keeping M4A file',
|
||||
);
|
||||
_log.d('Metadata and cover embedded successfully');
|
||||
} catch (e) {
|
||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||
}
|
||||
} else {
|
||||
_log.w('FFmpeg conversion returned null, keeping M4A file');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('SAF M4A->FLAC conversion failed: $e');
|
||||
} finally {
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
if (flacPath != null) {
|
||||
try {
|
||||
await File(flacPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
|
||||
}
|
||||
} else {
|
||||
if (quality == 'HIGH') {
|
||||
final tidalHighFormat = settings.tidalHighFormat;
|
||||
_log.i(
|
||||
'Tidal HIGH quality download, converting M4A to $tidalHighFormat...',
|
||||
);
|
||||
|
||||
try {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
|
||||
final format = tidalHighFormat.startsWith('opus')
|
||||
? 'opus'
|
||||
: 'mp3';
|
||||
final convertedPath = await FFmpegService.convertM4aToLossy(
|
||||
currentFilePath,
|
||||
format: format,
|
||||
bitrate: tidalHighFormat,
|
||||
deleteOriginal: true,
|
||||
);
|
||||
|
||||
if (convertedPath != null) {
|
||||
filePath = convertedPath;
|
||||
final bitrateDisplay = tidalHighFormat.contains('_')
|
||||
? '${tidalHighFormat.split('_').last}kbps'
|
||||
: '320kbps';
|
||||
actualQuality = '${format.toUpperCase()} $bitrateDisplay';
|
||||
_log.i(
|
||||
'Successfully converted M4A to $format: $convertedPath',
|
||||
);
|
||||
|
||||
_log.i('Embedding metadata to $format...');
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.99,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (format == 'mp3') {
|
||||
await _embedMetadataToMp3(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
convertedPath,
|
||||
trackToDownload,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
_log.d('Metadata embedded successfully');
|
||||
} else {
|
||||
_log.w('M4A to $format conversion failed, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('M4A conversion process failed: $e, keeping M4A file');
|
||||
actualQuality = 'AAC 320kbps';
|
||||
}
|
||||
} else {
|
||||
_log.d(
|
||||
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||
);
|
||||
|
||||
try {
|
||||
final file = File(currentFilePath);
|
||||
if (!await file.exists()) {
|
||||
_log.e('File does not exist at path: $filePath');
|
||||
} else {
|
||||
final length = await file.length();
|
||||
_log.i('File size before conversion: ${length / 1024} KB');
|
||||
|
||||
if (length < 1024) {
|
||||
_log.w(
|
||||
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
||||
);
|
||||
} else {
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(
|
||||
currentFilePath,
|
||||
);
|
||||
|
||||
if (flacPath != null) {
|
||||
filePath = flacPath;
|
||||
_log.d('Converted to FLAC: $flacPath');
|
||||
|
||||
_log.d(
|
||||
'Embedding metadata and cover to converted FLAC...',
|
||||
);
|
||||
try {
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
if (backendGenre != null ||
|
||||
backendLabel != null ||
|
||||
backendCopyright != null) {
|
||||
_log.d(
|
||||
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
||||
);
|
||||
}
|
||||
|
||||
await _embedMetadataAndCover(
|
||||
flacPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
_log.d('Metadata and cover embedded successfully');
|
||||
} catch (e) {
|
||||
_log.w('Warning: Failed to embed metadata/cover: $e');
|
||||
}
|
||||
} else {
|
||||
_log.w(
|
||||
'FFmpeg conversion returned null, keeping M4A file',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'FFmpeg conversion process failed: $e, keeping M4A file',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (metadataEmbeddingEnabled &&
|
||||
@@ -4690,7 +4967,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
? backendAlbum
|
||||
: trackToDownload.albumName,
|
||||
albumArtist: historyAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
|
||||
filePath: filePath,
|
||||
storageMode: effectiveSafMode ? 'saf' : 'app',
|
||||
downloadTreeUri: effectiveSafMode
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
|
||||
final _log = AppLogger('ExploreProvider');
|
||||
|
||||
@@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = {
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
final data = {'sections': sections.map((s) => s.toJson()).toList()};
|
||||
await prefs.setString(_cacheKey, jsonEncode(data));
|
||||
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
|
||||
_log.d('Saved ${sections.length} explore sections to cache');
|
||||
@@ -216,16 +215,16 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
|
||||
|
||||
|
||||
// If we have cached content and it's fresh enough, skip network fetch
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
if (!forceRefresh &&
|
||||
state.hasContent &&
|
||||
state.lastFetched != null &&
|
||||
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
|
||||
_log.d('Using cached home feed (fresh enough)');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (state.isLoading) {
|
||||
_log.d('Home feed fetch already in progress');
|
||||
return;
|
||||
@@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
|
||||
try {
|
||||
final extState = ref.read(extensionProvider);
|
||||
_log.d('Extensions count: ${extState.extensions.length}');
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final preferredId = settings.homeFeedProvider;
|
||||
_log.d(
|
||||
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
|
||||
);
|
||||
|
||||
Extension? targetExt;
|
||||
for (final extension in extState.extensions) {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
continue;
|
||||
}
|
||||
// If user has a preference, use that
|
||||
if (preferredId != null &&
|
||||
preferredId.isNotEmpty &&
|
||||
extension.id == preferredId) {
|
||||
targetExt = extension;
|
||||
break;
|
||||
}
|
||||
// Otherwise take the first available (fallback to spotify-web if found)
|
||||
if (targetExt == null || extension.id == 'spotify-web') {
|
||||
targetExt = extension;
|
||||
if (extension.id == 'spotify-web') {
|
||||
if (preferredId == null && extension.id == 'spotify-web') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (targetExt == null) {
|
||||
_log.w('No extension with homeFeed capability found');
|
||||
state = state.copyWith(
|
||||
@@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
_log.i('Fetching home feed from ${targetExt.id}...');
|
||||
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
|
||||
|
||||
@@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
_log.d('getExtensionHomeFeed success=$success');
|
||||
if (!success) {
|
||||
final error = result['error'] as String? ?? 'Unknown error';
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: error,
|
||||
);
|
||||
state = state.copyWith(isLoading: false, error: error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
.toList();
|
||||
|
||||
_log.i('Fetched ${sections.length} sections');
|
||||
|
||||
|
||||
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
|
||||
final firstItem = sections.first.items.first;
|
||||
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
|
||||
_log.d(
|
||||
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
|
||||
);
|
||||
}
|
||||
|
||||
final localGreeting = _getLocalGreeting();
|
||||
@@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
|
||||
}
|
||||
|
||||
|
||||
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
|
||||
return ExploreNotifier();
|
||||
});
|
||||
|
||||
@@ -504,6 +504,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
Future<void> _cleanupExtensions({required String reason}) async {
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
_cleanupInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.cleanupExtensions();
|
||||
_log.d('Extensions cleaned up ($reason)');
|
||||
@@ -519,6 +524,17 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
state = state.copyWith(
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
extensions: const [],
|
||||
error: null,
|
||||
);
|
||||
_log.i('Extension system disabled on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionSystem(extensionsDir, dataDir);
|
||||
await loadExtensions(extensionsDir);
|
||||
|
||||
@@ -329,6 +329,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
}
|
||||
final persistedItems =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
@@ -341,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
items: persistedItems,
|
||||
isScanning: false,
|
||||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
@@ -350,11 +355,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Full scan complete: ${items.length} tracks found, '
|
||||
'Full scan complete: ${persistedItems.length} tracks found, '
|
||||
'$skippedDownloads already in downloads',
|
||||
);
|
||||
await _showScanCompleteNotification(
|
||||
totalTracks: items.length,
|
||||
totalTracks: persistedItems.length,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
@@ -439,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
// Build the incremental merge base from SQLite, not the current
|
||||
// provider state. Startup auto-scan can fire before `state.items` has
|
||||
// finished loading, which would otherwise drop unchanged rows from the
|
||||
// in-memory library until a manual full rescan.
|
||||
final existingJson = await _db.getAll();
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in state.items) item.filePath: item,
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
item.filePath: item,
|
||||
};
|
||||
final existingDownloadedPaths = <String>[];
|
||||
currentByPath.removeWhere((path, _) {
|
||||
@@ -491,8 +502,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Deleted $deleteCount items from database');
|
||||
}
|
||||
|
||||
final items = currentByPath.values.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
final items =
|
||||
(await _db.getAll())
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.toList(growable: false)
|
||||
..sort(_compareLibraryItems);
|
||||
|
||||
final now = DateTime.now();
|
||||
try {
|
||||
|
||||
@@ -53,6 +53,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) {
|
||||
_log.w('Failed to sync lyrics providers to backend: $e');
|
||||
});
|
||||
@@ -68,6 +70,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void _syncNetworkCompatibilitySettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
final compatibilityMode = state.networkCompatibilityMode;
|
||||
PlatformBridge.setNetworkCompatibilityOptions(
|
||||
allowHttp: compatibilityMode,
|
||||
@@ -117,10 +121,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
useCustomSpotifyCredentials: false,
|
||||
);
|
||||
}
|
||||
// Migration 6: Tidal HIGH quality removed — migrate to LOSSLESS
|
||||
if (state.audioQuality == 'HIGH') {
|
||||
state = state.copyWith(audioQuality: 'LOSSLESS');
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
@@ -375,6 +375,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setCreatePlaylistFolder(bool enabled) {
|
||||
state = state.copyWith(createPlaylistFolder: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAlbumArtistForFolders(bool enabled) {
|
||||
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
||||
_saveSettings();
|
||||
@@ -406,8 +411,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setMetadataSource(String source) {
|
||||
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
|
||||
state = state.copyWith(metadataSource: normalized);
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -420,6 +424,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHomeFeedProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearHomeFeedProvider: true);
|
||||
} else {
|
||||
state = state.copyWith(homeFeedProvider: provider);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
@@ -451,6 +464,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setTidalHighFormat(String format) {
|
||||
state = state.copyWith(tidalHighFormat: format);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeOpusBitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
@@ -30,6 +31,8 @@ class TrackState {
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final String?
|
||||
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -52,6 +55,7 @@ class TrackState {
|
||||
this.isShowingRecentAccess = false,
|
||||
this.searchExtensionId,
|
||||
this.selectedSearchFilter,
|
||||
this.searchSource,
|
||||
});
|
||||
|
||||
bool get hasContent =>
|
||||
@@ -83,6 +87,8 @@ class TrackState {
|
||||
String? searchExtensionId,
|
||||
String? selectedSearchFilter,
|
||||
bool clearSelectedSearchFilter = false,
|
||||
String? searchSource,
|
||||
bool clearSearchSource = false,
|
||||
}) {
|
||||
return TrackState(
|
||||
tracks: tracks ?? this.tracks,
|
||||
@@ -108,6 +114,9 @@ class TrackState {
|
||||
selectedSearchFilter: clearSelectedSearchFilter
|
||||
? null
|
||||
: (selectedSearchFilter ?? this.selectedSearchFilter),
|
||||
searchSource: clearSearchSource
|
||||
? null
|
||||
: (searchSource ?? this.searchSource),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -278,7 +287,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
playlistName: type == 'playlist'
|
||||
? result['name'] as String?
|
||||
: null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
coverUrl: normalizeCoverReference(
|
||||
result['cover_url']?.toString(),
|
||||
),
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
@@ -305,10 +316,12 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl:
|
||||
artistData['image_url'] as String? ??
|
||||
artistData['images'] as String?,
|
||||
headerImageUrl: artistData['header_image'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
(artistData['image_url'] ?? artistData['images'])?.toString(),
|
||||
),
|
||||
headerImageUrl: normalizeRemoteHttpUrl(
|
||||
artistData['header_image']?.toString(),
|
||||
),
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
@@ -349,7 +362,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: id,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -363,7 +376,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
playlistName: playlistInfo['name'] as String?,
|
||||
coverUrl: playlistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(
|
||||
playlistInfo['images']?.toString(),
|
||||
),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'artist') {
|
||||
@@ -377,7 +392,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -414,7 +429,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: 'qobuz:$id',
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -427,8 +442,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl =
|
||||
(playlistInfo['images'] ?? owner?['images']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -447,7 +463,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -484,7 +500,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: 'tidal:$id',
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -497,8 +513,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl =
|
||||
(playlistInfo['images'] ?? owner?['images']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -517,7 +534,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -572,7 +589,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
albumId: parsed['id'] as String?,
|
||||
albumName: albumInfo['name'] as String?,
|
||||
coverUrl: albumInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
|
||||
);
|
||||
_preWarmCacheForTracks(tracks);
|
||||
} else if (type == 'playlist') {
|
||||
@@ -584,8 +601,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
|
||||
final playlistName =
|
||||
(playlistInfo['name'] ?? owner?['name']) as String?;
|
||||
final coverUrl =
|
||||
(playlistInfo['images'] ?? owner?['images']) as String?;
|
||||
final coverUrl = normalizeRemoteHttpUrl(
|
||||
(playlistInfo['images'] ?? owner?['images'])?.toString(),
|
||||
);
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
@@ -604,7 +622,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
artistId: artistInfo['id'] as String?,
|
||||
artistName: artistInfo['name'] as String?,
|
||||
coverUrl: artistInfo['images'] as String?,
|
||||
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
|
||||
artistAlbums: albums,
|
||||
);
|
||||
}
|
||||
@@ -618,7 +636,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query, {String? filterOverride}) async {
|
||||
Future<void> search(
|
||||
String query, {
|
||||
String? filterOverride,
|
||||
String? builtInSearchProvider,
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve selected filter during loading
|
||||
@@ -640,39 +662,68 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
// Determine the effective search provider
|
||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||
|
||||
_log.i(
|
||||
'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
|
||||
'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
try {
|
||||
_log.d('Calling metadata provider search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'Metadata providers returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Metadata provider search failed, falling back to Deezer tracks: $e',
|
||||
);
|
||||
// Only use metadata providers for Deezer search (default behavior)
|
||||
if (effectiveProvider == 'deezer') {
|
||||
try {
|
||||
_log.d('Calling metadata provider search API...');
|
||||
metadataTrackResults =
|
||||
await PlatformBridge.searchTracksWithMetadataProviders(
|
||||
query,
|
||||
limit: 20,
|
||||
includeExtensions: includeExtensions,
|
||||
);
|
||||
_log.i(
|
||||
'Metadata providers returned ${metadataTrackResults.length} tracks',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w(
|
||||
'Metadata provider search failed, falling back to Deezer tracks: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
// Call the appropriate search API
|
||||
switch (effectiveProvider) {
|
||||
case 'tidal':
|
||||
_log.d('Calling Tidal search API...');
|
||||
results = await PlatformBridge.searchTidalAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
case 'qobuz':
|
||||
_log.d('Calling Qobuz search API...');
|
||||
results = await PlatformBridge.searchQobuzAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
_log.d('Calling Deezer search API...');
|
||||
results = await PlatformBridge.searchDeezerAll(
|
||||
query,
|
||||
trackLimit: 20,
|
||||
artistLimit: 2,
|
||||
filter: currentFilter,
|
||||
);
|
||||
break;
|
||||
}
|
||||
_log.i(
|
||||
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
|
||||
);
|
||||
|
||||
if (!_isRequestValid(requestId)) {
|
||||
@@ -758,6 +809,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter: currentFilter, // Preserve filter in results
|
||||
searchSource:
|
||||
effectiveProvider, // Track which service was used for search
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -943,7 +996,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
albumArtist: data['album_artist'] as String?,
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -958,26 +1011,32 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
|
||||
final itemType = data['item_type']?.toString();
|
||||
final effectiveSource =
|
||||
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
|
||||
? (nativeId.isNotEmpty ? nativeId : spotifyId)
|
||||
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
id: preferredId,
|
||||
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(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(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:
|
||||
source ??
|
||||
data['source']?.toString() ??
|
||||
data['provider_id']?.toString(),
|
||||
source: effectiveSource,
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
@@ -1015,7 +1074,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
providerId: data['provider_id']?.toString(),
|
||||
@@ -1026,7 +1087,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return SearchArtist(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
followers: data['followers'] as int? ?? 0,
|
||||
popularity: data['popularity'] as int? ?? 0,
|
||||
);
|
||||
@@ -1037,7 +1098,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
releaseDate: data['release_date'] as String?,
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
@@ -1049,7 +1110,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
owner: data['owner'] as String? ?? '',
|
||||
imageUrl: data['images'] as String?,
|
||||
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
@@ -94,7 +95,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
.recordAlbumAccess(
|
||||
id: widget.albumId,
|
||||
name: widget.albumName,
|
||||
artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName,
|
||||
artistName:
|
||||
widget.artistName ??
|
||||
widget.tracks?.firstOrNull?.albumArtist ??
|
||||
widget.tracks?.firstOrNull?.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
@@ -226,7 +230,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistId:
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
|
||||
albumId: data['album_id']?.toString() ?? widget.albumId,
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: normalizeCoverReference(data['images']?.toString()),
|
||||
isrc: data['isrc'] as String?,
|
||||
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -280,7 +284,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
) {
|
||||
final expandedHeight = _calculateExpandedHeight(context);
|
||||
final tracks = _tracks ?? [];
|
||||
final artistName = widget.artistName ??
|
||||
final artistName =
|
||||
widget.artistName ??
|
||||
(tracks.isNotEmpty
|
||||
? (tracks.first.albumArtist ?? tracks.first.artistName)
|
||||
: null);
|
||||
@@ -574,17 +579,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
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) ||
|
||||
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(
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
@@ -617,7 +626,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(tracksToQueue, service, qualityOverride: quality);
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
);
|
||||
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
|
||||
},
|
||||
);
|
||||
@@ -633,9 +646,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Widget _buildLoveAllButton() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart'
|
||||
show ExtensionAlbumScreen;
|
||||
@@ -297,8 +298,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
.toList();
|
||||
}
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
if (topTracksList.isNotEmpty) {
|
||||
topTracks = topTracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
@@ -399,8 +399,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString() ?? album?.id,
|
||||
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl)
|
||||
?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -414,18 +415,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
final totalTracksValue = data['total_tracks'];
|
||||
final totalTracks =
|
||||
totalTracksValue is int
|
||||
? totalTracksValue
|
||||
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
|
||||
final totalTracks = totalTracksValue is int
|
||||
? totalTracksValue
|
||||
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
|
||||
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: (data['name'] ?? data['title'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: totalTracks,
|
||||
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art'])
|
||||
?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
|
||||
),
|
||||
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
|
||||
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
|
||||
.toString(),
|
||||
@@ -1359,8 +1360,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
},
|
||||
itemBuilder: (context, pageIndex) {
|
||||
final startIndex = pageIndex * tracksPerPage;
|
||||
final endIndex =
|
||||
(startIndex + tracksPerPage).clamp(0, tracks.length);
|
||||
final endIndex = (startIndex + tracksPerPage).clamp(
|
||||
0,
|
||||
tracks.length,
|
||||
);
|
||||
final pageTracks = tracks.sublist(startIndex, endIndex);
|
||||
|
||||
return Column(
|
||||
|
||||
@@ -946,8 +946,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
String selectedFormat = formats.first;
|
||||
bool isLosslessTarget =
|
||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||
String selectedBitrate =
|
||||
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
String selectedBitrate = isLosslessTarget
|
||||
? '320k'
|
||||
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -1009,8 +1010,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate =
|
||||
format == 'Opus' ? '128k' : '320k';
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1055,11 +1057,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1175,7 +1174,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
int successCount = 0;
|
||||
final total = selected.length;
|
||||
final historyDb = HistoryDatabase.instance;
|
||||
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
|
||||
final newQuality =
|
||||
(targetFormat.toUpperCase() == 'ALAC' ||
|
||||
targetFormat.toUpperCase() == 'FLAC')
|
||||
? '${targetFormat.toUpperCase()} Lossless'
|
||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||
@@ -1206,12 +1206,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final v = value.toString().trim();
|
||||
if (v.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = v;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
|
||||
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
@@ -489,6 +490,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
if (searchProvider == null || searchProvider.isEmpty) return false;
|
||||
|
||||
// Built-in providers (tidal, qobuz) also support live search
|
||||
if (_builtInSearchProviders.contains(searchProvider)) return true;
|
||||
|
||||
final extension = extState.extensions
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
@@ -546,6 +550,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in search providers that are not extensions
|
||||
static const _builtInSearchProviders = {'tidal', 'qobuz'};
|
||||
|
||||
Future<void> _performSearch(String query, {String? filterOverride}) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final extState = ref.read(extensionProvider);
|
||||
@@ -558,9 +565,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null &&
|
||||
_builtInSearchProviders.contains(searchProvider);
|
||||
|
||||
final isExtensionEnabled =
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isBuiltInProvider &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
@@ -571,10 +583,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.customSearch(searchProvider, query, options: options);
|
||||
} else if (isBuiltInProvider) {
|
||||
// Use built-in Tidal or Qobuz search
|
||||
await ref
|
||||
.read(trackProvider.notifier)
|
||||
.search(
|
||||
query,
|
||||
filterOverride: selectedFilter,
|
||||
builtInSearchProvider: searchProvider,
|
||||
);
|
||||
} else {
|
||||
if (searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
!isExtensionEnabled) {
|
||||
!isExtensionEnabled &&
|
||||
!isBuiltInProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
await ref
|
||||
@@ -718,6 +740,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: trackState.searchSource,
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -2770,6 +2793,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
// Check built-in providers first
|
||||
if (searchProvider == 'tidal') {
|
||||
return 'Search with Tidal...';
|
||||
}
|
||||
if (searchProvider == 'qobuz') {
|
||||
return 'Search with Qobuz...';
|
||||
}
|
||||
|
||||
final ext = extState.extensions
|
||||
.where((e) => e.id == searchProvider)
|
||||
.firstOrNull;
|
||||
@@ -3004,6 +3035,11 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
// Check if current provider is a built-in provider (tidal/qobuz)
|
||||
const builtInProviders = {'tidal', 'qobuz'};
|
||||
final isBuiltInProvider =
|
||||
currentProvider != null && builtInProviders.contains(currentProvider);
|
||||
|
||||
IconData displayIcon = Icons.search;
|
||||
String? iconPath;
|
||||
if (currentExt != null) {
|
||||
@@ -3011,10 +3047,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
if (currentExt.searchBehavior?.icon != null) {
|
||||
displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!);
|
||||
}
|
||||
}
|
||||
|
||||
if (searchProviders.isEmpty) {
|
||||
return const Icon(Icons.search);
|
||||
} else if (isBuiltInProvider) {
|
||||
displayIcon = Icons.music_note;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -3081,6 +3115,62 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Built-in Tidal search option
|
||||
PopupMenuItem<String>(
|
||||
value: 'tidal',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'tidal'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tidal',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'tidal'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == 'tidal')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Built-in Qobuz search option
|
||||
PopupMenuItem<String>(
|
||||
value: 'qobuz',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 20,
|
||||
color: currentProvider == 'qobuz'
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Qobuz',
|
||||
style: TextStyle(
|
||||
fontWeight: currentProvider == 'qobuz'
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (currentProvider == 'qobuz')
|
||||
Icon(Icons.check, size: 18, color: colorScheme.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
|
||||
...searchProviders.map(
|
||||
(ext) => PopupMenuItem<String>(
|
||||
@@ -4217,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
artists: (data['artists'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['cover_url']?.toString(),
|
||||
coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
|
||||
albumType: (data['album_type'] ?? 'album').toString(),
|
||||
providerId: (data['provider_id'] ?? widget.extensionId).toString(),
|
||||
);
|
||||
@@ -4242,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
|
||||
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
||||
widget.artistId,
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
|
||||
@@ -820,6 +820,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
final format = item.format?.toLowerCase();
|
||||
final lowerPath = item.filePath.toLowerCase();
|
||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||
final isM4A =
|
||||
format == 'm4a' ||
|
||||
format == 'aac' ||
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.aac');
|
||||
final isOpus =
|
||||
format == 'opus' ||
|
||||
format == 'ogg' ||
|
||||
@@ -833,6 +838,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
@@ -1450,12 +1461,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final v = value.toString().trim();
|
||||
if (v.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = v;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
|
||||
@@ -83,7 +83,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final extState = ref.read(extensionProvider);
|
||||
if (!extState.isInitialized) {
|
||||
_log.d('Waiting for extensions to initialize before handling URL...');
|
||||
// Wait up to 5 seconds for extensions to initialize
|
||||
for (int i = 0; i < 50; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
@@ -128,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
|
||||
albumId: data['album_id']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
coverUrl: normalizeCoverReference(
|
||||
(data['cover_url'] ?? data['images'])?.toString(),
|
||||
),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -532,7 +535,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
tooltip: context.l10n.tooltipAddToPlaylist,
|
||||
onPressed: _tracks.isEmpty
|
||||
? null
|
||||
: () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName),
|
||||
: () => showAddTracksToPlaylistSheet(
|
||||
context,
|
||||
ref,
|
||||
_tracks,
|
||||
playlistNamePrefill: widget.playlistName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -611,17 +619,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||
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) ||
|
||||
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(
|
||||
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||
null;
|
||||
final isInLocal =
|
||||
localLibState?.existsInLibrary(
|
||||
isrc: track.isrc,
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
@@ -679,9 +691,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
final message = skipped > 0
|
||||
? context.l10n.discographySkippedDownloaded(added, skipped)
|
||||
: context.l10n.snackbarAddedTracksToQueue(added);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-12
@@ -4400,6 +4400,11 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
final format = item.format?.toLowerCase();
|
||||
final lowerPath = item.filePath.toLowerCase();
|
||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||
final isM4A =
|
||||
format == 'm4a' ||
|
||||
format == 'aac' ||
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.aac');
|
||||
final isOpus =
|
||||
format == 'opus' ||
|
||||
format == 'ogg' ||
|
||||
@@ -4413,6 +4418,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
@@ -5082,7 +5093,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
|
||||
try {
|
||||
// Read metadata from file
|
||||
final metadata = <String, String>{
|
||||
'TITLE': item.trackName,
|
||||
'ARTIST': item.artistName,
|
||||
@@ -5091,12 +5101,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final v = value.toString().trim();
|
||||
if (v.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = v;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
@@ -5111,7 +5116,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
1000,
|
||||
);
|
||||
|
||||
// Extract cover art
|
||||
String? coverPath;
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
@@ -5126,7 +5130,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Handle SAF vs regular file
|
||||
String workingPath = item.filePath;
|
||||
final isSaf = isContentUri(item.filePath);
|
||||
String? safTempPath;
|
||||
@@ -5139,7 +5142,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
workingPath = safTempPath;
|
||||
}
|
||||
|
||||
// Convert
|
||||
final newPath = await FFmpegService.convertAudioFormat(
|
||||
inputPath: workingPath,
|
||||
targetFormat: targetFormat.toLowerCase(),
|
||||
@@ -5149,7 +5151,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
// Cleanup cover temp
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
await File(coverPath).delete();
|
||||
@@ -5478,7 +5479,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
icon: Icons.download_for_offline_outlined,
|
||||
label:
|
||||
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems),
|
||||
onPressed: () =>
|
||||
_queueSelectedLocalAsFlac(unifiedItems),
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -511,6 +511,30 @@ class _TranslatorsSection extends StatelessWidget {
|
||||
language: 'Japanese',
|
||||
flag: '🇯🇵',
|
||||
),
|
||||
_Translator(
|
||||
name: 'unkn0wn',
|
||||
crowdinUsername: 'rdclvi',
|
||||
language: 'Indonesian',
|
||||
flag: '🇮🇩',
|
||||
),
|
||||
_Translator(
|
||||
name: 'lunching1272',
|
||||
crowdinUsername: 'lunching1272',
|
||||
language: 'Chinese Simplified',
|
||||
flag: '🇨🇳',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Сергей Ильченко',
|
||||
crowdinUsername: 'Sega_Mostky',
|
||||
language: 'Russian',
|
||||
flag: '🇷🇺',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Girl-lass',
|
||||
crowdinUsername: 'Girl-lass',
|
||||
language: 'Chinese Simplified',
|
||||
flag: '🇨🇳',
|
||||
),
|
||||
_Translator(
|
||||
name: 'Kaan',
|
||||
crowdinUsername: 'glai',
|
||||
|
||||
@@ -164,7 +164,13 @@ class _RecentDonorsCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
const donorNames = <String>['micahRichie', 'a fan', 'mc nuggets jimmy', 'CJBGR'];
|
||||
const donorNames = <String>[
|
||||
'McNuggets Jimmy',
|
||||
'zcc09',
|
||||
'micahRichie',
|
||||
'a fan',
|
||||
'CJBGR',
|
||||
];
|
||||
|
||||
// Match SettingsGroup color logic
|
||||
final cardColor = isDark
|
||||
@@ -480,31 +486,77 @@ int _cr(String v) {
|
||||
}
|
||||
|
||||
// Highlighted supporters (hashes of names).
|
||||
const _cv = <int>{1211573191};
|
||||
const _cv = <int>{1211573191, 1003219236};
|
||||
|
||||
class _SupporterChip extends StatelessWidget {
|
||||
// Diamond tier supporters ($50+ donors).
|
||||
const _dv = <int>{560908930};
|
||||
|
||||
enum _SupporterTier { normal, gold, diamond }
|
||||
|
||||
_SupporterTier _tierOf(String name) {
|
||||
final h = _cr(name);
|
||||
if (_dv.contains(h)) return _SupporterTier.diamond;
|
||||
if (_cv.contains(h)) return _SupporterTier.gold;
|
||||
return _SupporterTier.normal;
|
||||
}
|
||||
|
||||
class _SupporterChip extends StatefulWidget {
|
||||
final String name;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _SupporterChip({required this.name, required this.colorScheme});
|
||||
|
||||
@override
|
||||
State<_SupporterChip> createState() => _SupporterChipState();
|
||||
}
|
||||
|
||||
class _SupporterChipState extends State<_SupporterChip>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _SupporterTier _tier;
|
||||
AnimationController? _shimmerController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tier = _tierOf(widget.name);
|
||||
if (_tier == _SupporterTier.diamond) {
|
||||
_shimmerController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2400),
|
||||
)..repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shimmerController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = _cv.contains(_cr(name));
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
if (_tier == _SupporterTier.diamond) {
|
||||
return _buildDiamondChip(isDark);
|
||||
}
|
||||
|
||||
final isGold = _tier == _SupporterTier.gold;
|
||||
const goldChipColor = Color(0xFFFFF8DC);
|
||||
const goldAccentColor = Color(0xFFB8860B);
|
||||
const goldDarkChipColor = Color(0xFF3A3000);
|
||||
|
||||
final chipColor = e ? goldChipColor : colorScheme.secondaryContainer;
|
||||
final accentColor = e ? goldAccentColor : colorScheme.primary;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor;
|
||||
final chipColor = isGold
|
||||
? goldChipColor
|
||||
: widget.colorScheme.secondaryContainer;
|
||||
final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
|
||||
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
|
||||
|
||||
return Material(
|
||||
color: effectiveChipColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: e
|
||||
decoration: isGold
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
@@ -520,10 +572,12 @@ class _SupporterChip extends StatelessWidget {
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: accentColor.withValues(alpha: 0.2),
|
||||
child: e
|
||||
child: isGold
|
||||
? Icon(Icons.star_rounded, size: 12, color: accentColor)
|
||||
: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
widget.name.isNotEmpty
|
||||
? widget.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -533,10 +587,12 @@ class _SupporterChip extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
name,
|
||||
widget.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: e ? accentColor : colorScheme.onSecondaryContainer,
|
||||
fontWeight: e ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isGold
|
||||
? accentColor
|
||||
: widget.colorScheme.onSecondaryContainer,
|
||||
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -544,6 +600,92 @@ class _SupporterChip extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiamondChip(bool isDark) {
|
||||
const diamondLight = Color(0xFFE8F4FD);
|
||||
const diamondDark = Color(0xFF0D2B3E);
|
||||
const diamondAccent = Color(0xFF4FC3F7);
|
||||
const diamondHighlight = Color(0xFFB3E5FC);
|
||||
|
||||
final chipBg = isDark ? diamondDark : diamondLight;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _shimmerController!,
|
||||
builder: (context, child) {
|
||||
final t = _shimmerController!.value;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment(-2.0 + 4.0 * t, 0.0),
|
||||
end: Alignment(-1.0 + 4.0 * t, 0.0),
|
||||
colors: [
|
||||
chipBg,
|
||||
isDark
|
||||
? diamondAccent.withValues(alpha: 0.18)
|
||||
: diamondHighlight.withValues(alpha: 0.7),
|
||||
chipBg,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
border: Border.all(
|
||||
color: diamondAccent.withValues(
|
||||
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
|
||||
),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: diamondAccent.withValues(
|
||||
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
|
||||
),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
diamondAccent.withValues(alpha: 0.3),
|
||||
diamondAccent.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.diamond_rounded,
|
||||
size: 12,
|
||||
color: diamondAccent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.name,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: isDark ? diamondHighlight : diamondAccent,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeLine extends StatelessWidget {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
@@ -300,6 +301,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
final isTidalService = settings.defaultService == 'tidal';
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
@@ -407,8 +409,37 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||
showDivider: false,
|
||||
showDivider: isTidalService,
|
||||
),
|
||||
// Lossy 320kbps option (Tidal only) - downloads M4A AAC from server, converts to MP3/Opus
|
||||
if (isTidalService)
|
||||
_QualityOption(
|
||||
title: context.l10n.downloadLossy320,
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
context,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
isSelected: settings.audioQuality == 'HIGH',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('HIGH'),
|
||||
showDivider: false,
|
||||
),
|
||||
if (isTidalService && settings.audioQuality == 'HIGH')
|
||||
SettingsItem(
|
||||
icon: Icons.tune,
|
||||
title: context.l10n.downloadLossyFormat,
|
||||
subtitle: _getTidalHighFormatLabel(
|
||||
context,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
onTap: () => _showTidalHighFormatPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.tidalHighFormat,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
if (!isBuiltInService) ...[
|
||||
Padding(
|
||||
@@ -436,7 +467,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
],
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
subtitle: '${settings.youtubeOpusBitrate}kbps (128/256/320)',
|
||||
subtitle:
|
||||
'${settings.youtubeOpusBitrate}kbps (128/256/320)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
@@ -515,8 +547,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
icon: Icons.translate_outlined,
|
||||
title: context.l10n.downloadNeteaseIncludeTranslation,
|
||||
subtitle: settings.lyricsIncludeTranslationNetease
|
||||
? context.l10n.downloadNeteaseIncludeTranslationEnabled
|
||||
: context.l10n.downloadNeteaseIncludeTranslationDisabled,
|
||||
? context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeTranslationEnabled
|
||||
: context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeTranslationDisabled,
|
||||
value: settings.lyricsIncludeTranslationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -526,8 +562,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
icon: Icons.text_fields_outlined,
|
||||
title: context.l10n.downloadNeteaseIncludeRomanization,
|
||||
subtitle: settings.lyricsIncludeRomanizationNetease
|
||||
? context.l10n.downloadNeteaseIncludeRomanizationEnabled
|
||||
: context.l10n.downloadNeteaseIncludeRomanizationDisabled,
|
||||
? context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeRomanizationEnabled
|
||||
: context
|
||||
.l10n
|
||||
.downloadNeteaseIncludeRomanizationDisabled,
|
||||
value: settings.lyricsIncludeRomanizationNetease,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -627,6 +667,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
settings.folderOrganization,
|
||||
),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.playlist_play_outlined,
|
||||
title: context.l10n.downloadCreatePlaylistSourceFolder,
|
||||
subtitle: _getPlaylistFolderSubtitle(settings),
|
||||
value: settings.createPlaylistFolder,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setCreatePlaylistFolder(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.person_search_outlined,
|
||||
title: context.l10n.downloadUseAlbumArtistForFolders,
|
||||
@@ -642,7 +691,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseAlbumArtistForFolders(value),
|
||||
),
|
||||
SettingsItem(
|
||||
SettingsItem(
|
||||
icon: Icons.filter_alt_outlined,
|
||||
title: context.l10n.downloadArtistNameFilters,
|
||||
subtitle: _getArtistFolderFilterSubtitle(
|
||||
@@ -1142,7 +1191,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||
if (Platform.isIOS) {
|
||||
_showIOSDirectoryOptions(context, ref);
|
||||
} else {
|
||||
} else if (Platform.isAndroid) {
|
||||
_showAndroidDirectoryOptions(context, ref);
|
||||
}
|
||||
}
|
||||
@@ -1407,6 +1456,16 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
String _getPlaylistFolderSubtitle(AppSettings settings) {
|
||||
if (settings.folderOrganization == 'playlist') {
|
||||
return context.l10n.downloadCreatePlaylistSourceFolderRedundant;
|
||||
}
|
||||
if (settings.createPlaylistFolder) {
|
||||
return context.l10n.downloadCreatePlaylistSourceFolderEnabled;
|
||||
}
|
||||
return context.l10n.downloadCreatePlaylistSourceFolderDisabled;
|
||||
}
|
||||
|
||||
String _getArtistFolderFilterSubtitle(
|
||||
BuildContext context, {
|
||||
required bool usePrimaryArtistOnly,
|
||||
@@ -1532,6 +1591,104 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
return normalized.replaceAll(RegExp(r'[^a-z0-9\-_]'), '');
|
||||
}
|
||||
|
||||
String _getTidalHighFormatLabel(BuildContext context, String format) {
|
||||
switch (format) {
|
||||
case 'mp3_320':
|
||||
return context.l10n.downloadLossyMp3;
|
||||
case 'opus_256':
|
||||
return context.l10n.downloadLossyOpus256;
|
||||
case 'opus_128':
|
||||
return context.l10n.downloadLossyOpus128;
|
||||
default:
|
||||
return context.l10n.downloadLossyMp3;
|
||||
}
|
||||
}
|
||||
|
||||
void _showTidalHighFormatPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String current,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320Format,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.downloadLossy320FormatDesc,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.audiotrack),
|
||||
title: Text(context.l10n.downloadLossyMp3),
|
||||
subtitle: Text(context.l10n.downloadLossyMp3Subtitle),
|
||||
trailing: current == 'mp3_320'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('mp3_320');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus256),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus256Subtitle),
|
||||
trailing: current == 'opus_256'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_256');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.graphic_eq),
|
||||
title: Text(context.l10n.downloadLossyOpus128),
|
||||
subtitle: Text(context.l10n.downloadLossyOpus128Subtitle),
|
||||
trailing: current == 'opus_128'
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setTidalHighFormat('opus_128');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showYoutubeBitratePicker({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
@@ -1776,17 +1933,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadSongLinkRegion,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
child: Text(
|
||||
context.l10n.downloadSongLinkRegion,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.downloadSongLinkRegionDesc,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.downloadSongLinkRegionDesc,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -1847,12 +2004,12 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.downloadFolderOrganization,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.downloadFolderOrganization,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/explore_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
@@ -151,6 +152,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
_DownloadPriorityItem(),
|
||||
_MetadataPriorityItem(),
|
||||
_SearchProviderSelector(),
|
||||
_HomeFeedProviderSelector(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -586,6 +588,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
class _SearchProviderSelector extends ConsumerWidget {
|
||||
const _SearchProviderSelector();
|
||||
|
||||
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
@@ -596,20 +600,29 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
.where((e) => e.enabled && e.hasCustomSearch)
|
||||
.toList();
|
||||
|
||||
// Always allow tapping: built-in providers are always available
|
||||
final hasAnyProvider =
|
||||
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
|
||||
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == settings.searchProvider)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
// Check built-in first
|
||||
if (_builtInProviders.containsKey(settings.searchProvider)) {
|
||||
currentProviderName = _builtInProviders[settings.searchProvider]!;
|
||||
} else {
|
||||
final ext = searchProviders
|
||||
.where((e) => e.id == settings.searchProvider)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: searchProviders.isEmpty
|
||||
onTap: !hasAnyProvider
|
||||
? null
|
||||
: () => _showSearchProviderPicker(
|
||||
context,
|
||||
@@ -623,7 +636,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.manage_search,
|
||||
color: searchProviders.isEmpty
|
||||
color: !hasAnyProvider
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -635,14 +648,12 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Text(
|
||||
context.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
: null,
|
||||
color: !hasAnyProvider ? colorScheme.outline : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
searchProviders.isEmpty
|
||||
!hasAnyProvider
|
||||
? context.l10n.extensionsNoCustomSearch
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
@@ -654,7 +665,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: searchProviders.isEmpty
|
||||
color: !hasAnyProvider
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -682,61 +693,245 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
ctx.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
ctx.l10n.extensionsSearchProviderDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
ctx.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||
trailing:
|
||||
(settings.searchProvider == null ||
|
||||
settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
...searchProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(
|
||||
ext.searchBehavior?.placeholder ??
|
||||
ctx.l10n.extensionsCustomSearch,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
ctx.l10n.extensionsSearchProviderDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||
trailing:
|
||||
(settings.searchProvider == null ||
|
||||
settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(ext.id);
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
..._builtInProviders.entries.map(
|
||||
(entry) => ListTile(
|
||||
leading: Icon(Icons.search, color: colorScheme.tertiary),
|
||||
title: Text(entry.value),
|
||||
subtitle: Text('Search with ${entry.value}'),
|
||||
trailing: settings.searchProvider == entry.key
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(entry.key);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (searchProviders.isNotEmpty) const Divider(height: 1),
|
||||
...searchProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(
|
||||
ext.searchBehavior?.placeholder ??
|
||||
ctx.l10n.extensionsCustomSearch,
|
||||
),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider(ext.id);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeFeedProviderSelector extends ConsumerWidget {
|
||||
const _HomeFeedProviderSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final homeFeedProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasHomeFeed)
|
||||
.toList();
|
||||
|
||||
final hasAnyProvider = homeFeedProviders.isNotEmpty;
|
||||
|
||||
String currentProviderName = 'Auto';
|
||||
if (settings.homeFeedProvider != null &&
|
||||
settings.homeFeedProvider!.isNotEmpty) {
|
||||
final ext = homeFeedProviders
|
||||
.where((e) => e.id == settings.homeFeedProvider)
|
||||
.firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.homeFeedProvider!;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: !hasAnyProvider
|
||||
? null
|
||||
: () => _showHomeFeedProviderPicker(
|
||||
context,
|
||||
ref,
|
||||
settings,
|
||||
homeFeedProviders,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.explore_outlined,
|
||||
color: !hasAnyProvider
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Home Feed Provider',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: !hasAnyProvider ? colorScheme.outline : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
!hasAnyProvider
|
||||
? 'No extensions with home feed'
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: !hasAnyProvider
|
||||
? colorScheme.outline
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showHomeFeedProviderPicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
dynamic settings,
|
||||
List<Extension> homeFeedProviders,
|
||||
) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Home Feed Provider',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose which extension provides the home feed on the main screen',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
|
||||
title: const Text('Auto'),
|
||||
subtitle: const Text('Automatically select the best available'),
|
||||
trailing:
|
||||
(settings.homeFeedProvider == null ||
|
||||
settings.homeFeedProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setHomeFeedProvider(null);
|
||||
ref.read(exploreProvider.notifier).refresh();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
...homeFeedProviders.map(
|
||||
(ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text('Use ${ext.displayName} home feed'),
|
||||
trailing: settings.homeFeedProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setHomeFeedProvider(ext.id);
|
||||
ref.read(exploreProvider.notifier).refresh();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -73,11 +73,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS doesn't need explicit storage permission for app documents
|
||||
setState(() => _hasStoragePermission = true);
|
||||
} else {
|
||||
setState(() => _hasStoragePermission = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _requestStoragePermission() async {
|
||||
if (Platform.isIOS) return true;
|
||||
if (!Platform.isAndroid) return true;
|
||||
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
|
||||
if (_androidSdkVersion >= 29) return true;
|
||||
|
||||
@@ -135,8 +137,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
if (Platform.isIOS) {
|
||||
// On iOS, create a security-scoped bookmark so we can access
|
||||
// this folder across app restarts and from the Go backend.
|
||||
final bookmark =
|
||||
await PlatformBridge.createIosBookmarkFromPath(result);
|
||||
final bookmark = await PlatformBridge.createIosBookmarkFromPath(
|
||||
result,
|
||||
);
|
||||
if (bookmark != null && bookmark.isNotEmpty) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -182,11 +185,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(localLibraryProvider.notifier).startScan(
|
||||
libraryPath,
|
||||
forceFullScan: forceFullScan,
|
||||
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||
);
|
||||
await ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.startScan(
|
||||
libraryPath,
|
||||
forceFullScan: forceFullScan,
|
||||
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cancelScan() async {
|
||||
@@ -272,10 +277,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.libraryAutoScan,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@@ -293,7 +297,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
selected: current == 'off',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryAutoScan('off');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -303,7 +309,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
selected: current == 'on_open',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryAutoScan('on_open');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -313,7 +321,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
selected: current == 'daily',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryAutoScan('daily');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -323,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
selected: current == 'weekly',
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly');
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryAutoScan('weekly');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -443,9 +455,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
child: SettingsItem(
|
||||
icon: Icons.autorenew_rounded,
|
||||
title: context.l10n.libraryAutoScan,
|
||||
subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan),
|
||||
subtitle: _getAutoScanLabel(
|
||||
context,
|
||||
settings.localLibraryAutoScan,
|
||||
),
|
||||
onTap: settings.localLibraryEnabled
|
||||
? () => _showAutoScanPicker(context, settings.localLibraryAutoScan)
|
||||
? () => _showAutoScanPicker(
|
||||
context,
|
||||
settings.localLibraryAutoScan,
|
||||
)
|
||||
: null,
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -950,9 +968,7 @@ class _AutoScanOption extends StatelessWidget {
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
trailing: selected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
trailing: selected ? Icon(Icons.check, color: colorScheme.primary) : null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -611,24 +611,34 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
final ValueChanged<String> onChanged;
|
||||
const _MetadataSourceSelector({required this.onChanged});
|
||||
|
||||
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
final searchProvider = settings.searchProvider ?? '';
|
||||
final isBuiltIn = _builtInProviders.containsKey(searchProvider);
|
||||
|
||||
Extension? activeExtension;
|
||||
if (settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty) {
|
||||
if (searchProvider.isNotEmpty && !isBuiltIn) {
|
||||
activeExtension = extState.extensions
|
||||
.where((e) => e.id == settings.searchProvider && e.enabled)
|
||||
.where((e) => e.id == searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
}
|
||||
final hasExtensionSearch = activeExtension != null;
|
||||
final hasNonDefaultProvider = isBuiltIn || activeExtension != null;
|
||||
|
||||
String? extensionName;
|
||||
if (hasExtensionSearch) {
|
||||
extensionName = activeExtension.displayName;
|
||||
String subtitle;
|
||||
if (isBuiltIn) {
|
||||
subtitle = 'Using ${_builtInProviders[searchProvider]}';
|
||||
} else if (activeExtension != null) {
|
||||
subtitle = context.l10n.optionsUsingExtension(
|
||||
activeExtension.displayName,
|
||||
);
|
||||
} else {
|
||||
subtitle = context.l10n.optionsPrimaryProviderSubtitle;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -644,11 +654,9 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasExtensionSearch
|
||||
? context.l10n.optionsUsingExtension(extensionName!)
|
||||
: context.l10n.optionsPrimaryProviderSubtitle,
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: hasExtensionSearch
|
||||
color: hasNonDefaultProvider
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -659,17 +667,41 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: 'Deezer',
|
||||
isSelected: !hasExtensionSearch,
|
||||
isSelected: searchProvider.isEmpty,
|
||||
onTap: () {
|
||||
if (hasExtensionSearch) {
|
||||
if (hasNonDefaultProvider) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
onChanged('deezer');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SourceChip(
|
||||
icon: Icons.waves,
|
||||
label: 'Tidal',
|
||||
isSelected: searchProvider == 'tidal',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('tidal');
|
||||
onChanged('tidal');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SourceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: searchProvider == 'qobuz',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSearchProvider('qobuz');
|
||||
onChanged('qobuz');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasExtensionSearch) ...[
|
||||
if (activeExtension != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -91,6 +91,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_storagePermissionGranted = true;
|
||||
_notificationPermissionGranted = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +144,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Permission error: $e');
|
||||
@@ -225,7 +232,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
await _showIOSDirectoryOptions();
|
||||
} else {
|
||||
} else if (Platform.isAndroid) {
|
||||
final result = await PlatformBridge.pickSafTree();
|
||||
if (result != null) {
|
||||
final treeUri = result['tree_uri'] as String? ?? '';
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
|
||||
@@ -518,7 +519,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
String get _filePath =>
|
||||
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
||||
String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl;
|
||||
String? get _coverUrl =>
|
||||
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
|
||||
String? get _localCoverPath =>
|
||||
_isLocalItem ? _localLibraryItem!.coverPath : null;
|
||||
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;
|
||||
@@ -1778,6 +1780,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final isFlac = lower.endsWith('.flac');
|
||||
final isMp3 = lower.endsWith('.mp3');
|
||||
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
|
||||
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
|
||||
|
||||
bool success = false;
|
||||
String? error;
|
||||
@@ -1803,7 +1806,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
} else {
|
||||
error = result['error']?.toString() ?? l10nFailedToEmbedLyrics;
|
||||
}
|
||||
} else if (isMp3 || isOpus) {
|
||||
} else if (isMp3 || isOpus || isM4A) {
|
||||
final metadata = _buildFallbackMetadata();
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(workingPath);
|
||||
@@ -1838,6 +1841,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: workingPath,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: workingPath,
|
||||
@@ -2321,6 +2330,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (lower.endsWith('.m4a') || lower.endsWith('.aac')) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
@@ -2737,6 +2752,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
put('COPYRIGHT', source['copyright']);
|
||||
put('COMPOSER', source['composer']);
|
||||
put('COMMENT', source['comment']);
|
||||
put('LYRICS', source['lyrics']);
|
||||
put('UNSYNCEDLYRICS', source['lyrics']);
|
||||
|
||||
final trackNumber = source['track_number'];
|
||||
if (trackNumber != null && trackNumber.toString() != '0') {
|
||||
@@ -2796,8 +2813,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
void _showConvertSheet(BuildContext context) {
|
||||
final currentFormat = _currentFileFormat;
|
||||
final isLosslessSource =
|
||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
|
||||
// Build available target formats based on source
|
||||
final formats = <String>[];
|
||||
@@ -2879,8 +2895,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
isLosslessTarget =
|
||||
format == 'ALAC' || format == 'FLAC';
|
||||
if (!isLosslessTarget) {
|
||||
selectedBitrate =
|
||||
format == 'Opus' ? '128k' : '320k';
|
||||
selectedBitrate = format == 'Opus'
|
||||
? '128k'
|
||||
: '320k';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2929,11 +2946,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.trackConvertLosslessHint,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -3499,22 +3513,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
SnackBar(content: Text(context.l10n.trackConvertConverting)),
|
||||
);
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
final shouldEmbedLyrics =
|
||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||
final metadata = _buildFallbackMetadata();
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
if (result['error'] == null) {
|
||||
result.forEach((key, value) {
|
||||
if (key == 'error' || value == null) return;
|
||||
final normalizedValue = value.toString().trim();
|
||||
if (normalizedValue.isEmpty) return;
|
||||
metadata[key.toUpperCase()] = normalizedValue;
|
||||
});
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
} else {
|
||||
_log.w('readFileMetadata returned error, using fallback metadata');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('readFileMetadata threw, using fallback metadata: $e');
|
||||
}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
metadata: metadata,
|
||||
sourcePath: cleanFilePath,
|
||||
shouldEmbedLyrics: shouldEmbedLyrics,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
spotifyId: _spotifyId ?? '',
|
||||
durationMs: (duration ?? 0) * 1000,
|
||||
);
|
||||
|
||||
String? coverPath;
|
||||
try {
|
||||
@@ -4921,6 +4942,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final lower = widget.filePath.toLowerCase();
|
||||
final isMp3 = lower.endsWith('.mp3');
|
||||
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
|
||||
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
|
||||
|
||||
final vorbisMap = <String, String>{};
|
||||
if (metadata['title']?.isNotEmpty == true) {
|
||||
@@ -4964,6 +4986,18 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
if (metadata['comment']?.isNotEmpty == true) {
|
||||
vorbisMap['COMMENT'] = metadata['comment']!;
|
||||
}
|
||||
try {
|
||||
final existingMetadata = await PlatformBridge.readFileMetadata(
|
||||
ffmpegTarget,
|
||||
);
|
||||
final existingLyrics = existingMetadata['lyrics']?.toString().trim();
|
||||
if (existingLyrics != null && existingLyrics.isNotEmpty) {
|
||||
vorbisMap['LYRICS'] = existingLyrics;
|
||||
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
|
||||
}
|
||||
} catch (_) {
|
||||
// Lyrics preservation is best-effort.
|
||||
}
|
||||
|
||||
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
|
||||
String? extractedCoverPath;
|
||||
@@ -4997,6 +5031,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
|
||||
@@ -37,7 +37,6 @@ class CoverCacheManager {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
_cachePath = p.join(appDir.path, 'cover_cache');
|
||||
|
||||
// Ensure cache directory exists
|
||||
await Directory(_cachePath!).create(recursive: true);
|
||||
|
||||
debugPrint('CoverCacheManager: Initializing at $_cachePath');
|
||||
@@ -48,7 +47,6 @@ class CoverCacheManager {
|
||||
debugPrint('CoverCacheManager: Initialized successfully');
|
||||
} catch (e) {
|
||||
debugPrint('CoverCacheManager: Failed to initialize: $e');
|
||||
// Will fallback to DefaultCacheManager
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,25 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<FFmpegResult> _executeWithArguments(
|
||||
List<String> arguments,
|
||||
) async {
|
||||
try {
|
||||
final session = await FFmpegKit.executeWithArguments(arguments);
|
||||
final returnCode = await session.getReturnCode();
|
||||
final output = await session.getOutput() ?? '';
|
||||
|
||||
return FFmpegResult(
|
||||
success: ReturnCode.isSuccess(returnCode),
|
||||
returnCode: returnCode?.getValue() ?? -1,
|
||||
output: output,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('FFmpeg executeWithArguments error: $e');
|
||||
return FFmpegResult(success: false, returnCode: -1, output: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
@@ -1030,18 +1049,24 @@ class FFmpegService {
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$opusPath" ');
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
cmdBuffer.write('-map_metadata:s:a -1 ');
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
final arguments = <String>[
|
||||
'-i',
|
||||
opusPath,
|
||||
'-map',
|
||||
'0:a',
|
||||
'-map_metadata',
|
||||
'-1',
|
||||
'-map_metadata:s:a',
|
||||
'-1',
|
||||
'-c:a',
|
||||
'copy',
|
||||
];
|
||||
|
||||
if (metadata != null) {
|
||||
metadata.forEach((key, value) {
|
||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('$key=$value');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1049,8 +1074,9 @@ class FFmpegService {
|
||||
try {
|
||||
final pictureBlock = await _createMetadataBlockPicture(coverPath);
|
||||
if (pictureBlock != null) {
|
||||
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
|
||||
arguments
|
||||
..add('-metadata')
|
||||
..add('METADATA_BLOCK_PICTURE=$pictureBlock');
|
||||
_log.d(
|
||||
'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)',
|
||||
);
|
||||
@@ -1062,12 +1088,12 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
arguments
|
||||
..add(tempOutput)
|
||||
..add('-y');
|
||||
_log.d('Executing FFmpeg Opus embed command');
|
||||
|
||||
final result = await _execute(command);
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
@@ -1106,6 +1132,88 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> embedMetadataToM4a({
|
||||
required String m4aPath,
|
||||
String? coverPath,
|
||||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.m4a');
|
||||
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$m4aPath" ');
|
||||
|
||||
final hasCover = coverPath != null && await File(coverPath).exists();
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-i "$coverPath" ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
|
||||
// For M4A/MP4, cover art is mapped as a video stream and stored in the
|
||||
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
|
||||
// flag is only valid for Matroska/WebM containers and must NOT be used here.
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy ');
|
||||
}
|
||||
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
|
||||
if (metadata != null) {
|
||||
final m4aMetadata = _convertToM4aTags(metadata);
|
||||
for (final entry in m4aMetadata.entries) {
|
||||
final sanitizedValue = entry.value.replaceAll('"', '\\"');
|
||||
cmdBuffer.write('-metadata ${entry.key}="$sanitizedValue" ');
|
||||
}
|
||||
}
|
||||
|
||||
cmdBuffer.write('"$tempOutput" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d(
|
||||
'Executing FFmpeg M4A embed command: ${_previewCommandForLog(command)}',
|
||||
);
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final originalFile = File(m4aPath);
|
||||
|
||||
if (await tempFile.exists()) {
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(m4aPath);
|
||||
await tempFile.delete();
|
||||
|
||||
_log.d('M4A metadata embedded successfully');
|
||||
return m4aPath;
|
||||
} else {
|
||||
_log.e('Temp M4A output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.e('Failed to replace M4A file after metadata embed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to cleanup temp M4A file: $e');
|
||||
}
|
||||
|
||||
_log.e('M4A Metadata embed failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> _createMetadataBlockPicture(String imagePath) async {
|
||||
try {
|
||||
final file = File(imagePath);
|
||||
@@ -1330,7 +1438,8 @@ class FFmpegService {
|
||||
cmdBuffer.write('-i "$inputPath" ');
|
||||
|
||||
// Cover art as second input for M4A attached picture
|
||||
final hasCover = coverPath != null &&
|
||||
final hasCover =
|
||||
coverPath != null &&
|
||||
coverPath.trim().isNotEmpty &&
|
||||
await File(coverPath).exists();
|
||||
if (hasCover) {
|
||||
@@ -1338,8 +1447,10 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
cmdBuffer.write('-map 0:a ');
|
||||
// M4A/MP4 containers store cover art in the 'covr' atom automatically.
|
||||
// '-disposition attached_pic' is only for Matroska/WebM and must NOT be used here.
|
||||
if (hasCover) {
|
||||
cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
|
||||
cmdBuffer.write('-map 1:v -c:v copy ');
|
||||
}
|
||||
cmdBuffer.write('-c:a alac ');
|
||||
cmdBuffer.write('-map_metadata -1 ');
|
||||
@@ -1389,7 +1500,8 @@ class FFmpegService {
|
||||
final cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$inputPath" ');
|
||||
|
||||
final hasCover = coverPath != null &&
|
||||
final hasCover =
|
||||
coverPath != null &&
|
||||
coverPath.trim().isNotEmpty &&
|
||||
await File(coverPath).exists();
|
||||
if (hasCover) {
|
||||
@@ -1508,9 +1620,7 @@ class FFmpegService {
|
||||
}
|
||||
|
||||
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
|
||||
static Map<String, String> _convertToM4aTags(
|
||||
Map<String, String> metadata,
|
||||
) {
|
||||
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
|
||||
final m4aMap = <String, String>{};
|
||||
|
||||
for (final entry in metadata.entries) {
|
||||
@@ -1548,6 +1658,9 @@ class FFmpegService {
|
||||
case 'GENRE':
|
||||
m4aMap['genre'] = value;
|
||||
break;
|
||||
case 'ISRC':
|
||||
m4aMap['isrc'] = value;
|
||||
break;
|
||||
case 'COMPOSER':
|
||||
m4aMap['composer'] = value;
|
||||
break;
|
||||
@@ -1557,6 +1670,10 @@ class FFmpegService {
|
||||
case 'COPYRIGHT':
|
||||
m4aMap['copyright'] = value;
|
||||
break;
|
||||
case 'LABEL':
|
||||
case 'ORGANIZATION':
|
||||
m4aMap['organization'] = value;
|
||||
break;
|
||||
case 'LYRICS':
|
||||
case 'UNSYNCEDLYRICS':
|
||||
m4aMap['lyrics'] = value;
|
||||
@@ -1648,7 +1765,11 @@ class FFmpegService {
|
||||
final outputPaths = <String>[];
|
||||
final inputExt = audioPath.toLowerCase().split('.').last;
|
||||
// For lossless formats, keep as FLAC; for others, keep original format
|
||||
final outputExt = (inputExt == 'flac' || inputExt == 'wav' || inputExt == 'ape' || inputExt == 'wv')
|
||||
final outputExt =
|
||||
(inputExt == 'flac' ||
|
||||
inputExt == 'wav' ||
|
||||
inputExt == 'ape' ||
|
||||
inputExt == 'wv')
|
||||
? 'flac'
|
||||
: inputExt;
|
||||
|
||||
@@ -1681,7 +1802,9 @@ class FFmpegService {
|
||||
cmdBuffer.write('-c:a copy ');
|
||||
}
|
||||
|
||||
final artist = track.artist.isNotEmpty ? track.artist : (albumMetadata['artist'] ?? '');
|
||||
final artist = track.artist.isNotEmpty
|
||||
? track.artist
|
||||
: (albumMetadata['artist'] ?? '');
|
||||
final album = albumMetadata['album'] ?? '';
|
||||
final genre = albumMetadata['genre'] ?? '';
|
||||
final date = albumMetadata['date'] ?? '';
|
||||
@@ -1706,7 +1829,9 @@ class FFmpegService {
|
||||
cmdBuffer.write('"$outputPath" -y');
|
||||
|
||||
final command = cmdBuffer.toString();
|
||||
_log.d('CUE split track ${track.number}: ${_previewCommandForLog(command)}');
|
||||
_log.d(
|
||||
'CUE split track ${track.number}: ${_previewCommandForLog(command)}',
|
||||
);
|
||||
|
||||
final result = await _execute(command);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -12,7 +12,6 @@ final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
/// Cached current iOS container path for path normalization
|
||||
String? _currentContainerPath;
|
||||
|
||||
/// SQLite database service for download history
|
||||
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
|
||||
class HistoryDatabase {
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
@@ -78,7 +77,6 @@ class HistoryDatabase {
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes for fast lookups
|
||||
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
|
||||
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
|
||||
await db.execute(
|
||||
@@ -171,7 +169,6 @@ class HistoryDatabase {
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
// Get all items with iOS paths
|
||||
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||
int updatedCount = 0;
|
||||
final batch = db.batch();
|
||||
@@ -198,7 +195,6 @@ class HistoryDatabase {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
// Save current container path
|
||||
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||
|
||||
_log.i('iOS path migration complete: $updatedCount paths updated');
|
||||
@@ -323,7 +319,6 @@ class HistoryDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
/// Insert or update a history item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
@@ -345,7 +340,6 @@ class HistoryDatabase {
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -403,26 +397,22 @@ class HistoryDatabase {
|
||||
return rows.map((r) => r['spotify_id'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete by ID
|
||||
Future<void> deleteById(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Delete by Spotify ID
|
||||
Future<void> deleteBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('history');
|
||||
_log.i('Cleared all history');
|
||||
}
|
||||
|
||||
/// Get total count
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
||||
@@ -459,7 +449,6 @@ class HistoryDatabase {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
|
||||
@@ -123,7 +123,7 @@ class LibraryDatabase {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 4, // Bumped version for bitrate column
|
||||
version: 4,
|
||||
onConfigure: (db) async {
|
||||
await db.rawQuery('PRAGMA journal_mode = WAL');
|
||||
await db.execute('PRAGMA synchronous = NORMAL');
|
||||
@@ -331,13 +331,11 @@ class LibraryDatabase {
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
}) async {
|
||||
// First try ISRC if available
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
final byIsrc = await getByIsrc(isrc);
|
||||
if (byIsrc != null) return byIsrc;
|
||||
}
|
||||
|
||||
// Then try name matching
|
||||
if (trackName != null && artistName != null) {
|
||||
final matches = await findByTrackAndArtist(trackName, artistName);
|
||||
if (matches.isNotEmpty) return matches.first;
|
||||
@@ -523,7 +521,6 @@ class LibraryDatabase {
|
||||
return rows.map((r) => r['file_path'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete multiple items by their file paths
|
||||
Future<int> deleteByPaths(List<String> filePaths) async {
|
||||
if (filePaths.isEmpty) return 0;
|
||||
final db = await database;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -14,6 +15,11 @@ class PlatformBridge {
|
||||
'com.zarz.spotiflac/library_scan_progress_stream',
|
||||
);
|
||||
|
||||
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static bool get supportsExtensionSystem =>
|
||||
Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||
_log.d('parseSpotifyUrl: $url');
|
||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
||||
@@ -503,6 +509,36 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchTidalAll(
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
int artistLimit = 2,
|
||||
String? filter,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchTidalAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
'filter': filter ?? '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> searchQobuzAll(
|
||||
String query, {
|
||||
int trackLimit = 15,
|
||||
int artistLimit = 2,
|
||||
String? filter,
|
||||
}) async {
|
||||
final result = await _channel.invokeMethod('searchQobuzAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
'filter': filter ?? '',
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
|
||||
String artistId, {
|
||||
int limit = 12,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -10,8 +11,9 @@ class ShareIntentService {
|
||||
ShareIntentService._internal();
|
||||
|
||||
// Spotify patterns
|
||||
static final RegExp _spotifyUriPattern =
|
||||
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
|
||||
static final RegExp _spotifyUriPattern = RegExp(
|
||||
r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+',
|
||||
);
|
||||
static final RegExp _spotifyUrlPattern = RegExp(
|
||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||
);
|
||||
@@ -56,6 +58,11 @@ class ShareIntentService {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
_log.i('Share intent is not supported on this platform');
|
||||
return;
|
||||
}
|
||||
|
||||
_mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen(
|
||||
_handleSharedMedia,
|
||||
onError: (err) => _log.e('Error: $err'),
|
||||
@@ -68,14 +75,14 @@ class ShareIntentService {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSharedMedia(List<SharedMediaFile> files, {bool isInitial = false}) {
|
||||
void _handleSharedMedia(
|
||||
List<SharedMediaFile> files, {
|
||||
bool isInitial = false,
|
||||
}) {
|
||||
for (final file in files) {
|
||||
// Check both path and message - apps may share URL in either field
|
||||
final textsToCheck = [
|
||||
file.path,
|
||||
if (file.message != null) file.message!,
|
||||
];
|
||||
|
||||
final textsToCheck = [file.path, if (file.message != null) file.message!];
|
||||
|
||||
for (final textToCheck in textsToCheck) {
|
||||
final url = _extractMusicUrl(textToCheck);
|
||||
if (url != null) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
@@ -24,20 +25,28 @@ class UpdateInfo {
|
||||
}
|
||||
|
||||
class UpdateChecker {
|
||||
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||
static const String _latestApiUrl =
|
||||
'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
|
||||
static const String _allReleasesApiUrl =
|
||||
'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
|
||||
|
||||
/// Check for updates based on channel preference
|
||||
/// [channel] can be 'stable' or 'preview'
|
||||
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
|
||||
if (!Platform.isAndroid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, dynamic>? releaseData;
|
||||
|
||||
|
||||
if (channel == 'preview') {
|
||||
final response = await http.get(
|
||||
Uri.parse('$_allReleasesApiUrl?per_page=10'),
|
||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse('$_allReleasesApiUrl?per_page=10'),
|
||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
_log.w('GitHub API returned ${response.statusCode}');
|
||||
@@ -49,13 +58,15 @@ class UpdateChecker {
|
||||
_log.i('No releases found');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
releaseData = releases.first as Map<String, dynamic>;
|
||||
} else {
|
||||
final response = await http.get(
|
||||
Uri.parse(_latestApiUrl),
|
||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final response = await http
|
||||
.get(
|
||||
Uri.parse(_latestApiUrl),
|
||||
headers: {'Accept': 'application/vnd.github.v3+json'},
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
_log.w('GitHub API returned ${response.statusCode}');
|
||||
@@ -68,19 +79,24 @@ class UpdateChecker {
|
||||
final tagName = releaseData['tag_name'] as String? ?? '';
|
||||
final latestVersion = tagName.replaceFirst('v', '');
|
||||
final isPrerelease = releaseData['prerelease'] as bool? ?? false;
|
||||
|
||||
|
||||
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
|
||||
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)');
|
||||
_log.i(
|
||||
'No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final body = releaseData['body'] as String? ?? 'No changelog available';
|
||||
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
|
||||
final htmlUrl =
|
||||
releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
|
||||
final publishedAt =
|
||||
DateTime.tryParse(releaseData['published_at'] as String? ?? '') ??
|
||||
DateTime.now();
|
||||
|
||||
String? arm64Url;
|
||||
String? universalUrl;
|
||||
|
||||
|
||||
final assets = releaseData['assets'] as List<dynamic>? ?? [];
|
||||
for (final asset in assets) {
|
||||
final name = (asset['name'] as String? ?? '').toLowerCase();
|
||||
@@ -98,12 +114,14 @@ class UpdateChecker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only arm64 is supported; fall back to universal if available
|
||||
final apkUrl = arm64Url ?? universalUrl;
|
||||
|
||||
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
|
||||
|
||||
_log.i(
|
||||
'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl',
|
||||
);
|
||||
|
||||
return UpdateInfo(
|
||||
version: latestVersion,
|
||||
changelog: body,
|
||||
@@ -122,7 +140,7 @@ class UpdateChecker {
|
||||
try {
|
||||
final latestBase = latest.split('-').first;
|
||||
final currentBase = current.split('-').first;
|
||||
|
||||
|
||||
final latestParts = latestBase.split('.').map(int.parse).toList();
|
||||
final currentParts = currentBase.split('.').map(int.parse).toList();
|
||||
|
||||
@@ -137,12 +155,12 @@ class UpdateChecker {
|
||||
if (latestParts[i] > currentParts[i]) return true;
|
||||
if (latestParts[i] < currentParts[i]) return false;
|
||||
}
|
||||
|
||||
|
||||
final latestHasSuffix = latest.contains('-');
|
||||
final currentHasSuffix = current.contains('-');
|
||||
|
||||
|
||||
if (!latestHasSuffix && currentHasSuffix) return true;
|
||||
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
_log.e('Error comparing versions: $e');
|
||||
|
||||
+12
-20
@@ -35,7 +35,6 @@ class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
/// Create dark theme
|
||||
static ThemeData dark({
|
||||
ColorScheme? dynamicScheme,
|
||||
Color? seedColor,
|
||||
@@ -88,12 +87,11 @@ class AppTheme {
|
||||
),
|
||||
);
|
||||
|
||||
/// Card theme
|
||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 12 -> 16
|
||||
),
|
||||
color: scheme.surfaceContainerLow,
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
@@ -104,18 +102,17 @@ class AppTheme {
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
|
||||
/// Filled button theme
|
||||
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
||||
FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
@@ -125,18 +122,17 @@ class AppTheme {
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
|
||||
/// Text button theme
|
||||
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
||||
TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 20 -> 16
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
);
|
||||
@@ -149,40 +145,39 @@ class AppTheme {
|
||||
foregroundColor: scheme.onPrimaryContainer,
|
||||
);
|
||||
|
||||
/// Input decoration theme
|
||||
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
||||
InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
), // Added transparency
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
), // consistent padding
|
||||
),
|
||||
);
|
||||
|
||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||
ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
), // 12 -> 16
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
);
|
||||
|
||||
@@ -193,7 +188,6 @@ class AppTheme {
|
||||
surfaceTintColor: scheme.surfaceTint,
|
||||
);
|
||||
|
||||
/// Navigation bar theme
|
||||
static NavigationBarThemeData _navigationBarTheme(
|
||||
ColorScheme scheme, {
|
||||
bool isAmoled = false,
|
||||
@@ -213,7 +207,6 @@ class AppTheme {
|
||||
contentTextStyle: TextStyle(color: scheme.onInverseSurface),
|
||||
);
|
||||
|
||||
/// Progress indicator theme
|
||||
static ProgressIndicatorThemeData _progressIndicatorTheme(
|
||||
ColorScheme scheme,
|
||||
) => ProgressIndicatorThemeData(
|
||||
@@ -243,7 +236,6 @@ class AppTheme {
|
||||
}),
|
||||
);
|
||||
|
||||
/// Chip theme
|
||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
backgroundColor: scheme.surfaceContainerLow,
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||
import 'package:spotiflac_android/theme/app_theme.dart';
|
||||
|
||||
/// Wrapper widget that provides dynamic color support from device wallpaper
|
||||
class DynamicColorWrapper extends ConsumerWidget {
|
||||
final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) builder;
|
||||
|
||||
@@ -23,7 +22,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
ColorScheme darkScheme;
|
||||
|
||||
if (themeSettings.useDynamicColor && lightDynamic != null && darkDynamic != null) {
|
||||
// Use dynamic colors from wallpaper (Android 12+)
|
||||
lightScheme = lightDynamic;
|
||||
darkScheme = darkDynamic;
|
||||
} else {
|
||||
@@ -38,7 +36,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply AMOLED mode if enabled (pure black background)
|
||||
if (themeSettings.useAmoled) {
|
||||
darkScheme = _applyAmoledColors(darkScheme);
|
||||
}
|
||||
|
||||
@@ -74,3 +74,38 @@ Future<void> ensureLyricsMetadataForConversion({
|
||||
metadata['LYRICS'] = lyrics;
|
||||
metadata['UNSYNCEDLYRICS'] = lyrics;
|
||||
}
|
||||
|
||||
void mergePlatformMetadataForTagEmbed({
|
||||
required Map<String, String> target,
|
||||
required Map<String, dynamic> source,
|
||||
}) {
|
||||
void put(String key, dynamic value) {
|
||||
final normalized = value?.toString().trim();
|
||||
if (normalized == null || normalized.isEmpty) return;
|
||||
target[key] = normalized;
|
||||
}
|
||||
|
||||
put('TITLE', source['title']);
|
||||
put('ARTIST', source['artist']);
|
||||
put('ALBUM', source['album']);
|
||||
put('ALBUMARTIST', source['album_artist']);
|
||||
put('DATE', source['date']);
|
||||
put('ISRC', source['isrc']);
|
||||
put('GENRE', source['genre']);
|
||||
put('ORGANIZATION', source['label']);
|
||||
put('COPYRIGHT', source['copyright']);
|
||||
put('COMPOSER', source['composer']);
|
||||
put('COMMENT', source['comment']);
|
||||
put('LYRICS', source['lyrics']);
|
||||
put('UNSYNCEDLYRICS', source['lyrics']);
|
||||
|
||||
final trackNumber = source['track_number'];
|
||||
if (trackNumber != null && trackNumber.toString() != '0') {
|
||||
put('TRACKNUMBER', trackNumber);
|
||||
}
|
||||
|
||||
final discNumber = source['disc_number'];
|
||||
if (discNumber != null && discNumber.toString() != '0') {
|
||||
put('DISCNUMBER', discNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,41 @@ String? normalizeOptionalString(String? value) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
final RegExp _windowsAbsolutePathPattern = RegExp(r'^[A-Za-z]:[\\/]');
|
||||
|
||||
bool _looksLikeLocalReference(String value) {
|
||||
return value.startsWith('/') ||
|
||||
value.startsWith('content://') ||
|
||||
value.startsWith('file://') ||
|
||||
_windowsAbsolutePathPattern.hasMatch(value);
|
||||
}
|
||||
|
||||
String? normalizeCoverReference(String? value) {
|
||||
final normalized = normalizeOptionalString(value);
|
||||
if (normalized == null) return null;
|
||||
|
||||
if (normalized.startsWith('//')) {
|
||||
return 'https:$normalized';
|
||||
}
|
||||
|
||||
if (normalized.startsWith('http://') ||
|
||||
normalized.startsWith('https://') ||
|
||||
_looksLikeLocalReference(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? normalizeRemoteHttpUrl(String? value) {
|
||||
final normalized = normalizeCoverReference(value);
|
||||
if (normalized == null) return null;
|
||||
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String formatSampleRateKHz(int sampleRate) {
|
||||
final khz = sampleRate / 1000;
|
||||
final precision = sampleRate % 1000 == 0 ? 0 : 1;
|
||||
|
||||
@@ -22,7 +22,6 @@ class BuiltInService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for built-in services
|
||||
/// Default quality options for each built-in service
|
||||
const _builtInServices = [
|
||||
BuiltInService(
|
||||
@@ -98,12 +97,12 @@ const _builtInServices = [
|
||||
),
|
||||
];
|
||||
|
||||
/// A reusable widget for selecting download service (built-in + extensions)
|
||||
class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
final String? trackName;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final void Function(String quality, String service) onSelect;
|
||||
final String? recommendedService; // Service to show as "(Recommended)"
|
||||
|
||||
const DownloadServicePicker({
|
||||
super.key,
|
||||
@@ -111,6 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
required this.onSelect,
|
||||
this.recommendedService,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -123,6 +123,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
String? trackName,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
String? recommendedService,
|
||||
required void Function(String quality, String service) onSelect,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -140,6 +141,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
artistName: artistName,
|
||||
coverUrl: coverUrl,
|
||||
onSelect: onSelect,
|
||||
recommendedService: recommendedService,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -154,7 +156,13 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedService = ref.read(settingsProvider).defaultService;
|
||||
// Default to recommended service if available, otherwise use default
|
||||
final recommended = widget.recommendedService;
|
||||
if (recommended != null && recommended.isNotEmpty) {
|
||||
_selectedService = recommended;
|
||||
} else {
|
||||
_selectedService = ref.read(settingsProvider).defaultService;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get quality options for the selected service
|
||||
@@ -284,6 +292,8 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
_ServiceChip(
|
||||
label: service.isDisabled
|
||||
? '${service.label} (${service.disabledReason})'
|
||||
: widget.recommendedService == service.id
|
||||
? '${service.label} (Recommended)'
|
||||
: service.label,
|
||||
isSelected: _selectedService == service.id,
|
||||
isDisabled: service.isDisabled,
|
||||
|
||||
@@ -169,6 +169,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -509,6 +517,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -661,6 +677,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.6.1"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1082,6 +1106,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_common_ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0+2"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1098,6 +1130,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||
publish_to: "none"
|
||||
version: 3.8.7+113
|
||||
version: 3.9.0+115
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -27,6 +27,7 @@ dependencies:
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.0
|
||||
sqflite: ^2.4.1
|
||||
sqflite_common_ffi: ^2.3.6
|
||||
|
||||
# HTTP & Network
|
||||
http: ^1.6.0
|
||||
|
||||
Reference in New Issue
Block a user